Использование паттерна MVP в Android

При разработке сложных приложений можно столкнуться с проблемами, которые, вероятно, возникали раньше и уже имеют большое количество решений. Такие решения называются паттернами (шаблонами). Как правило, говорят о паттернах дизайна и паттернах архитектуры. Они упрощают разработку приложений, поэтому целесообразно их использовать, если такая возможность есть.

Дизайн проекта должен быть приоритетной задачей с самого начала разработки. Одна из задач, которые нужно решить в первую очередь — это архитектура проекта, определяющая, как разные элементы нашего приложения будут соотноситься друг с другом.

Хотя Android предлагает отличный SDK, его архитектурные паттерны довольно необычны и могут мешать во время разработки, особенно при создании сложных приложений, которые необходимо тестировать и поддерживать в течение долгого времени. К счастью, мы можем сами выбирать, как хотим проектировать приложение.

Инструменты, предлагаемые Android, предлагают нам использовать модель MVC (Model View Controller). MVC представляет собой паттерн, который направлен на разделение ролей в приложении.

Архитектура в нём делится на три слоя:

  • Model;
  • View;
  • Controller.

Каждый слой отвечает за отдельный аспект приложения. Model отвечает за бизнес-логику, View — за пользовательский интерфейс, а Controller обеспечивает доступ к Model.

Однако, если проанализировать архитектуру Android, особенно отношение между View (Activity, Fragment и т.д.) и Model (структуры данных), то можно сделать вывод, что это нельзя считать MVC.  Если мы подумает о симбиотической связи между загрузчиками и активностями с фрагментами, то то вы сможете понять, почему нужно уделять пристальное внимание архитектуре Android. Активность или фрагмент отвечает за вызов загрузчика, который должен извлекать данные и возвращать их родителям. Его существование полностью привязано к его родителям и нет разделение между ролью View и бизнес-логикой, выполняемой загрузчиком.

Отсюда возникает проблемы:

  • Как можно использовать модульное тестирование в приложении, в котором данные тесно связаны с представлениями (активностями или фрагментами)?
  • Как найти проблему в чужом коде, если проект не придерживается какого-то паттерна и код может находиться буквально в любом месте?

Чтобы решить эти проблемы, нужно выбрать для разработки какую-то свою архитектуру. Примером хорошего паттерна может являться MVP (Model View Presenter). MVP является аналогом MVC, но с более современной парадигмой, которая создаёт лучшее разделение ролей и максимизирует тестируемость приложения.

MVP разделяет приложение на следующие уровни:

  • Model;
  • View;
  • Presenter.

У каждого из них есть свои обязанности и связь между ними происходит через Presenter.

Model содержит бизнес-логику приложения. Он контролирует как данные создаются, сохраняются и изменяются.

View это интерфейс, который отображает данные и направляет действия пользователя в Presenter.

Presenter выступает в роли посредника. Он извлекает данные из Model и показывает их в View. Он также обрабатывает действия пользователя, переданные ему из View.

Основным отличием от MVC здесь является то, что Presenter контролирует взаимодействие между View и Moder более строго, чем это делает Controller, в MVC слой View может сам извлекать данные из Model. Кроме того, MVP обладает и другими отличиями, делающими его более подходящим для построения архитектуры приложения:

  • View не имеет доступа к Model;
  • Presenter привязан к одному View;
  • View полностью пассивен;

Такое разделение слоёв позволяет лучше тестировать приложение, поскольку заранее известно, на каком уровне нужно искать код.

Реализуем паттерн MVP в одном из наших приложений (почитать о его реализации вы можете, перейдя по этой ссылке или по этой). Исходный код MainActivity в приложении выглядит следующим образом:

public class MainActivity extends AppCompatActivity
        implements NavigationView.OnNavigationItemSelectedListener, GetAppsTask.AsyncResponse,
        AppsAdapter.ClickListener {
    int REQUEST_UNINSTALL = 1;
    private SearchView mSearchView;
    private AppsAdapter mAppsAdapter;
    private List<AppInfo> mAppsList;
    public static List<AppInfo> mSelectedList;
    private int currentList = R.string.menu_installed;
    private LinearLayout footer;
    ProgressBar progressBar;
    private RecyclerView recyclerView;
    private GetIconThread<AppsAdapter.AppViewHolder> mGetIconTask;
    private IconCache iconCache;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        progressBar = findViewById(R.id.progress_spinner);
        recyclerView = findViewById(R.id.recyclerView);

        int memClass = ((ActivityManager) getApplicationContext().getSystemService(Context.ACTIVITY_SERVICE)).getMemoryClass();
        int cacheSize = 1024 * 1024 * memClass / 8;
        iconCache = new IconCache(cacheSize);

        Handler responseHandler = new Handler();
        mGetIconTask = new GetIconThread<>(responseHandler, getApplicationContext(), iconCache);
        mGetIconTask.setIconDownloadListener(
                new GetIconThread.GetIconTaskListener<AppsAdapter.AppViewHolder>() {
                    @Override
                    public void onIconDownloaded(AppsAdapter.AppViewHolder target, Drawable icon) {
                        target.bindDrawable(icon);
                    }
                });
        mGetIconTask.start();
        mGetIconTask.getLooper();

        mSelectedList = new ArrayList<>();

        DrawerLayout drawer = findViewById(R.id.drawer_layout);
        if (drawer != null) {
            ActionBarDrawerToggle toggle =
                    new ActionBarDrawerToggle(this, drawer, toolbar, R.string.navigation_drawer_open,
                            R.string.navigation_drawer_close);
            drawer.addDrawerListener(toggle);
            toggle.syncState();
        }
        NavigationView navigationView = findViewById(R.id.nav_view);
        navigationView.setNavigationItemSelectedListener(this);
        setTitle(R.string.loading);
        new GetAppsTask(this, this).execute();

        footer = findViewById(R.id.footer);
        Button delete = findViewById(R.id.delete);
        delete.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                delete();
            }
        });
        Button clear = findViewById(R.id.clear);
        clear.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                clearSelection();
            }
        });
    }

    private void clearSelection() {
        footer.setVisibility(View.GONE);
        mAppsAdapter.updateView(mSelectedList);
        mSelectedList.clear();
        // mAppsAdapter.notifyDataSetChanged();

    }

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (requestCode == REQUEST_UNINSTALL) {
            // 0 means success, other means failed.
            Log.d("TAG", "got result of uninstall: " + resultCode);
            updateAppList();
        }
    }

    private void updateAppList() {
        for (AppInfo ai : mSelectedList) {
            if (!Tools.isPackageInstalled(this, ai.packageName)) mAppsAdapter.remove(ai);
        }
        setTitle(getString(currentList) + " (" + mAppsAdapter.getItemCount() + ")");
        clearSelection();
    }

    private void delete() {
        if (currentList == R.string.menu_installed) {
            for (AppInfo ai : mSelectedList) {
                Uri packageURI = Uri.parse("package:" + ai.packageName);
                Intent uninstallIntent = new Intent(Intent.ACTION_DELETE, packageURI);
                startActivityForResult(uninstallIntent, REQUEST_UNINSTALL);
                // mAppsAdapter.remove(ai);
            }
        } else {
            setTitle(R.string.deleting);
            progressBar.setVisibility(View.VISIBLE);
            mSearchView.setVisibility(View.GONE);
            for (AppInfo ai : mSelectedList) {
                if (Tools.m521a(ai.publicSourceDir)) mAppsAdapter.remove(ai);
            }
            progressBar.setVisibility(View.GONE);
            mSearchView.setVisibility(View.VISIBLE);
            updateAppList();
            if (SP.getBoolean(this, SHOW_UNINSTALL_DIALOG, true)) uninstallDialog();
        }
    }

    @Override
    public void onBackPressed() {
        DrawerLayout drawer = findViewById(R.id.drawer_layout);
        if (drawer != null) {
            if (drawer.isDrawerOpen(GravityCompat.START)) {
                drawer.closeDrawer(GravityCompat.START);
            } else {
                if (footer.getVisibility() == View.VISIBLE) {
                    clearSelection();
                } else {
                    super.onBackPressed();
                }
            }
        } else {
            if (footer.getVisibility() == View.VISIBLE) {
                clearSelection();
            } else {
                super.onBackPressed();
            }
        }
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.main, menu);
        mSearchView = (SearchView) MenuItemCompat.getActionView(menu.findItem(R.id.action_search));
        mSearchView.setQueryHint(getString(R.string.search_hint));
        mSearchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
            @Override
            public boolean onQueryTextSubmit(String query) {
                search(query);
                return true;
            }

            @Override
            public boolean onQueryTextChange(String query) {
                search(query);
                return true;
            }
        });
        return true;
    }

    private void search(String query) {
        if (mAppsAdapter != null) mAppsAdapter.filter(query);
    }

    @Override
    public boolean onNavigationItemSelected(MenuItem item) {
        // Handle navigation view item clicks here.
        int id = item.getItemId();
        if (id == R.id.nav_installed) {
            currentList = R.string.menu_installed;
            if (mAppsList != null) {
                setAdapter(filterAppList());
            }
        } else if (id == R.id.nav_system) {
            currentList = R.string.menu_system;
            if (mAppsList != null) {
                setAdapter(filterAppList());
            }
            checkRootDialog();
        } else if (id == R.id.nav_settings) {
            Toast.makeText(this, R.string.settings_empty, Toast.LENGTH_LONG).show();
            // startActivity(new Intent(this, RootHelpActivity.class));
        } else if (id == R.id.nav_wiki) {
            openWikiPage();
        } else if (id == R.id.nav_share) {
            ShareCompat.IntentBuilder.from(this) // getActivity() or activity field if within Fragment
                    .setText(getString(R.string.send_message) + " " + getString(R.string.share_text))
                    .setType("text/plain") // most general text sharing MIME type
                    .setChooserTitle(R.string.share_title)
                    .startChooser();
        } else if (id == R.id.nav_send) {
            ShareCompat.IntentBuilder.from(this)
                    .setType("message/rfc822")
                    // .addEmailTo(email)
                    .setSubject(getString(R.string.app_name))
                    .setText(getString(R.string.send_message) + " " + getString(R.string.share_text))
                    //.setHtmlText(body) //If you are using HTML in your body text
                    .setChooserTitle(R.string.send_title)
                    .startChooser();
            // startActivity(new Intent(this, AboutActivity.class));
        } else if (id == R.id.nav_apps) {
            openMarket();
        }
        DrawerLayout drawer = findViewById(R.id.drawer_layout);
        if (drawer != null) drawer.closeDrawer(GravityCompat.START);
        return true;
    }

    private void openMarket() {
        Intent intent =
                new Intent(Intent.ACTION_VIEW, Uri.parse("market://search?q=pub:Android Tools (ru)"));
        if (intent.resolveActivity(getPackageManager()) != null) {
            startActivity(intent);
        } else {
            Toast.makeText(this, R.string.error_missing_market, Toast.LENGTH_LONG).show();
        }
    }

    private void openWikiPage() {
        Intent intentBrowser = new Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.wiki_url)));
        if (intentBrowser.resolveActivity(getPackageManager()) != null) {
            startActivity(intentBrowser);
        } else {
            Toast.makeText(this, R.string.error_missing_browser, Toast.LENGTH_LONG).show();
        }
    }

    private void checkRootDialog() {
        if (!Tools.checkRooted()) {
            AlertDialog.Builder builder = new AlertDialog.Builder(this);
            builder.setMessage(R.string.system_app_no_root_access).setTitle(R.string.common_warning);
            builder.setPositiveButton(R.string.root_help_title, new DialogInterface.OnClickListener() {
                public void onClick(DialogInterface dialog, int id) {
                    openWikiPage();
                }
            });
            builder.setNegativeButton(R.string.common_i_know, null);
            AlertDialog dialog = builder.create();
            dialog.show();
        }
    }

    private void uninstallDialog() {
        AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(this);
        LayoutInflater inflater = this.getLayoutInflater();
        View dialogView = inflater.inflate(R.layout.uninstall_dialog, null);
        dialogBuilder.setView(dialogView);
        CheckBox checkBox = dialogView.findViewById(R.id.checkBox);
        checkBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
            @Override
            public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
                if (isChecked) SP.setBoolean(MainActivity.this, SHOW_UNINSTALL_DIALOG, false);
            }
        });
        dialogBuilder.setPositiveButton(android.R.string.ok, null);
        dialogBuilder.create().show();
    }

    @Override
    public void onTaskComplete(List<AppInfo> result) {
        mAppsList = result;
        setAdapter(filterAppList());
    }

    private List<AppInfo> filterAppList() {
        List<AppInfo> list = new ArrayList<>();
        if (currentList == R.string.menu_installed) {
            for (AppInfo appInfo : mAppsList)
                if (!appInfo.isSystem) list.add(appInfo);
        } else {
            for (AppInfo appInfo : mAppsList)
                if (appInfo.isSystem) list.add(appInfo);
        }
        return list;
    }

    public void setAdapter(List<AppInfo> list) {
        sort(list);
        mAppsAdapter = new AppsAdapter(list, this, mGetIconTask);
        recyclerView.setLayoutManager(new LinearLayoutManager(this));
        recyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));
        recyclerView.setAdapter(mAppsAdapter);
        setTitle(getString(currentList) + " (" + mAppsAdapter.getItemCount() + ")");
    }

    public static void sort(List<AppInfo> apps) {
        Comparator<AppInfo> myComparator = new Comparator<AppInfo>() {
            public int compare(AppInfo obj1, AppInfo obj2) {
                return obj1.title.compareToIgnoreCase(obj2.title);
            }
        };
        Collections.sort(apps, myComparator);
    }

    @Override
    public void onItemClicked(AppInfo ai) {
        if (!mSelectedList.contains(ai)) {
            mSelectedList.add(ai);
        } else {
            mSelectedList.remove(ai);
        }
        if (mSelectedList.isEmpty()) {
            footer.setVisibility(View.GONE);
        } else {
            footer.setVisibility(View.VISIBLE);
        }
    }

    @Override
    public boolean onItemLongClicked(int position) {
        return false;
    }

    @Override
    protected void onStop() {
        super.onStop();
        mGetIconTask.clearQueue();
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        mGetIconTask.quit();
    }
}

На уровне Model в нашем приложении будет использоваться класс AppInfo, отвечающий за создание, хранение и изменение данных о приложениях.

public class AppInfo implements Serializable {
    public AppInfo(String title, String packageName, String sourceDir, String publicSourceDir, String versionName, int versionCode, boolean isSystem, String size, String dataDir, String nativeLibraryDir) {
        this.title = title;
        this.packageName = packageName;
        this.sourceDir = sourceDir;
        this.publicSourceDir = publicSourceDir;
        this.versionName = versionName;
        this.versionCode = versionCode;
        this.isSystem = isSystem;
        this.size = size;
        this.dataDir = dataDir;
        this.nativeLibraryDir = nativeLibraryDir;
    }

    public boolean isSystem;
    public String title;
    public String packageName;
    public String sourceDir;
    public String publicSourceDir;
    public String versionName;
    public String size;
    public int versionCode;
    public String dataDir, nativeLibraryDir;


    @Override
    public boolean equals(Object obj) {
        if (obj instanceof AppInfo) {
            AppInfo temp = (AppInfo) obj;
            if (this.title.equals(temp.title) && this.packageName.equals(temp.packageName))
                return true;
        }
        return false;
    }

    @Override
    public int hashCode() {
        return (this.title.hashCode() + this.packageName.hashCode());
    }
}

Для того, чтобы работать с данными из модели, создадим на этом же уровне специальный класс-интерактор, который будет сортировать списки приложений по категориям и возвращать итоговый результат в Presenter. Интерфейс класса будет состоять из одного метода filterApps(int currentList, List<AppInfo> mAppsList), который реализует сортировку.

public interface AppsInteractor {
    List<AppInfo> filterApps(int currentList, List<AppInfo> mAppsList);
}

Сама реализация выглядит следующим образом.

public class AppsInteractorImpl implements AppsInteractor {
    @Override
    public List<AppInfo> filterApps(int currentList, List<AppInfo> mAppsList) {
        List<AppInfo> list = new ArrayList<>();
        if (currentList == R.string.menu_installed) {
            for (AppInfo appInfo : mAppsList)
                if (!appInfo.isSystem) list.add(appInfo);
        } else {
            for (AppInfo appInfo : mAppsList)
                if (appInfo.isSystem) list.add(appInfo);
        }
        sortAppList(list);
        return list;
    }

    private static void sortAppList(List<AppInfo> apps) {
        Comparator<AppInfo> myComparator = new Comparator<AppInfo>() {
            public int compare(AppInfo obj1, AppInfo obj2) {
                return obj1.title.compareToIgnoreCase(obj2.title);
            }
        };
        Collections.sort(apps, myComparator);
    }
}

Преимущества использования интерактора в отдельном классе заключается в том, что он разбивает Presenter, делая его код более чистым и проверяемым, а также служит для абстракции логики данной модели. Класс-интерактор будет передаваться в Presenter при запуске приложения, поэтому у Presenter всегда будет к нему доступ.

Теперь нам нужно создать Presenter. Для этого необходимо перенести в отдельный класс обработку всех действий пользователя, а также работу с данными. Интерфейс получившегося презентера выглядит так:

public interface MainPresenter {
    void detachView();

    void attachView(MainView mainView);

    void downloadApps(GetAppsTask mGetAppsTask);

    void changeList(int change);

    void clearQueue();

    void deleteApp();

    void addToSelected(AppInfo ai);

    void clearSelection();

    void updateAppList();

    void prepareRootDialog(AlertDialog.Builder builder);

    void onWikiClick();

    void onMarketClick();

    void onSettingsClick();

    void onShareClick(ShareCompat.IntentBuilder intentBuilder);

    void onSendClick(ShareCompat.IntentBuilder intentBuilder);

    void showUninstallDialog(AlertDialog.Builder builder);
}

При запуске приложения в методе onCreate() активности мы создаём экземпляр Presenter и передаём ему интерактор.

mainPresenter = new MainPresenterImpl(new AppsInteractorImpl());

Таким образом, имеем следующий конструктор.

public MainPresenterImpl(AppsInteractor appsInteractor) {
    this.appsInteractor = appsInteractor;
    mAppsList = new ArrayList<>();
    mSelectedList = new ArrayList<>();
}

Затем необходимо передать в презентер родительскую View, для этого вызывается метод презентера attachView(MainView mainView).

@Override
public void attachView(MainView mainView) {
    this.mainView = mainView;
}

При завершении работы с активностью крайне важно затем откреплять View от Presenter, поскольку нам не нужно хранить View всё время. Как правило, экземпляр View следует очищать в onDestroy() или onPause() вашей активности, в зависимости от требований приложения. В данном случае удаление будет происходит в методе onDestroy().

@Override
public void onDestroy() {
    mainPresenter.detachView();
    super.onDestroy();
}

И собственно код выставление значения null экземпляру.

@Override
public void detachView() {
    mGetIconThread.quit();
    mainView = null;
}

После этого уже можно приступить к реализации методов, отвечающих за обработку действий пользователя и работу с данными в приложении. В результате получился следующий класс Presenter, связывающий уровни View и Model в нашем приложении.

public class MainPresenterImpl implements MainPresenter {
    private MainView mainView;
    private AppsInteractor appsInteractor;
    private List<AppInfo> mAppsList;
    private GetIconThread<AppsAdapter.AppViewHolder> mGetIconThread;
    private int currentList = R.string.menu_installed;
    private List<AppInfo> mSelectedList;

    public MainPresenterImpl(AppsInteractor appsInteractor) {
        this.appsInteractor = appsInteractor;
        mAppsList = new ArrayList<>();
        mSelectedList = new ArrayList<>();
    }

    @Override
    public void attachView(MainView mainView) {
        this.mainView = mainView;
    }

    @Override
    public void detachView() {
        mGetIconThread.quit();
        mainView = null;
    }

    @Override
    public void clearQueue() {
        mGetIconThread.clearQueue();
    }

    @Override
    public void downloadApps(GetAppsTask mGetAppTask) {
        mainView.onSetToolbarTitle(mainView.getStringFromResources(R.string.loading));
        setIconTask();
        mGetAppTask.setAsyncResponce(new GetAppsTask.AsyncResponse() {
            @Override
            public void onTaskComplete(List<AppInfo> result) {
                mAppsList = result;
                initAppsAdapter();
            }
        });
        mGetAppTask.execute();
    }

    @Override
    public void changeList(int change) {
        currentList = change;
        initAppsAdapter();
    }

    @Override
    public void addToSelected(AppInfo ai) {
        Log.d("TAG", "addToSelected");
        if (!mSelectedList.contains(ai)) {
            mSelectedList.add(ai);
        } else {
            mSelectedList.remove(ai);
        }
        mainView.onSelectionChanged(mSelectedList);
        if (mSelectedList.isEmpty()) {
            mainView.onGoneFooter();
        } else {
            mainView.onVisibleFooter();
        }
    }

    @Override
    public void clearSelection() {
        mSelectedList.clear();
        mainView.onSelectionClear();
    }

    @Override
    public void deleteApp() {
        Log.d("TAG", "deleteApp");
        if (currentList == R.string.menu_installed) {
            for (AppInfo ai : mSelectedList) {
                Uri packageURI = Uri.parse("package:" + ai.packageName);
                Intent uninstallIntent = new Intent(Intent.ACTION_DELETE, packageURI);
                mainView.onReadyActivityStartForResult(uninstallIntent);
                mAppsList.remove(ai);
            }
        } else {
            mainView.onSetToolbarTitle(mainView.getStringFromResources(R.string.deleting));
            mainView.onPrepareDeleteDialog(mSelectedList);
        }
    }

    @Override
    public void updateAppList() {
        Log.d("TAG", "updateAppList");
        for (AppInfo ai : mSelectedList) {
            if (!mainView.isPackageInstalled(ai.packageName)) {
                mainView.onUpdatedList(ai);
            }
        }
        List<AppInfo> list = appsInteractor.filterApps(currentList, mAppsList);
        mainView.onSetToolbarTitle(mainView.getStringFromResources(currentList) + " (" + list.size() + ")");
        clearSelection();
    }

    @Override
    public void prepareRootDialog(AlertDialog.Builder builder) {
        builder.setMessage(R.string.system_app_no_root_access).setTitle(R.string.common_warning);
        builder.setPositiveButton(R.string.root_help_title, new DialogInterface.OnClickListener() {
            public void onClick(DialogInterface dialog, int id) {
                onWikiClick();
            }
        });
        builder.setNegativeButton(R.string.common_i_know, null);
        builder.create().show();
    }

    @Override
    public void onWikiClick() {
        Intent intentBrowser = new Intent(Intent.ACTION_VIEW, Uri.parse(mainView.getStringFromResources(R.string.wiki_url)));
        if (intentBrowser.resolveActivity(mainView.getPackageManagerFromActivity()) != null) {
            mainView.onReadyActivityStart(intentBrowser);
        } else {
            mainView.onToastReady(mainView.getStringFromResources(R.string.error_missing_browser));
        }
    }

    @Override
    public void onMarketClick() {
        Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("market://search?q=pub:Android Tools (ru)"));
        if (intent.resolveActivity(mainView.getPackageManagerFromActivity()) != null) {
            mainView.onReadyActivityStart(intent);
        } else {
            mainView.onToastReady(mainView.getStringFromResources(R.string.error_missing_market));
        }
    }

    @Override
    public void onSettingsClick() {
        mainView.onToastReady(mainView.getStringFromResources(R.string.settings_empty));
    }

    @Override
    public void onShareClick(ShareCompat.IntentBuilder intentBuilder) {
        intentBuilder.setText(mainView.getStringFromResources(R.string.send_message) + " " + mainView.getStringFromResources(R.string.share_text));
        intentBuilder.setType("text/plain"); // most general text sharing MIME type
        intentBuilder.setChooserTitle(R.string.share_title);
        intentBuilder.startChooser();
    }

    @Override
    public void onSendClick(ShareCompat.IntentBuilder intentBuilder) {
        intentBuilder.setType("message/rfc822");
        intentBuilder.setSubject(mainView.getStringFromResources(R.string.app_name));
        intentBuilder.setText(mainView.getStringFromResources(R.string.send_message) + " " + mainView.getStringFromResources(R.string.share_text));
        intentBuilder.setChooserTitle(R.string.send_title);
        intentBuilder.startChooser();
    }

    @Override
    public void showUninstallDialog(AlertDialog.Builder builder) {
        View dialogView = mainView.getViewForDialog(R.layout.uninstall_dialog);
        builder.setView(dialogView);
        CheckBox checkBox = dialogView.findViewById(R.id.checkBox);
        checkBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
            @Override
            public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
                if (isChecked) {
                    mainView.saveCheckedUninstallDialog();
                }
            }
        });
        builder.setPositiveButton(android.R.string.ok, null);
        builder.create().show();
    }

    private void initAppsAdapter() {
        List<AppInfo> list = appsInteractor.filterApps(currentList, mAppsList);
        mainView.onSetToolbarTitle(mainView.getStringFromResources(currentList) + " (" + list.size() + ")");
        mainView.onDownloadApps(list, mGetIconThread, currentList);
    }

    private void setIconTask() {
        int memClass = mainView.getMemoryClassFromActivity();
        int cacheSize = 1024 * 1024 * memClass / 8;
        IconCache iconCache = new IconCache(cacheSize);

        Handler responseHandler = new Handler();
        mGetIconThread = new GetIconThread<>(responseHandler, mainView.getPackageManagerFromActivity(), iconCache);
        mGetIconThread.setIconDownloadListener(
                new GetIconThread.GetIconThreadListener<AppsAdapter.AppViewHolder>() {
                    @Override
                    public void onIconDownloaded(AppsAdapter.AppViewHolder target, Drawable icon) {
                        target.bindDrawable(icon);
                    }
                });
        mGetIconThread.start();
        mGetIconThread.getLooper();
    }
}

Для связи презентера с активностью реализуем интерфейс, содержащий в себе методы для возврата результатов работы на View или для запроса необходимых данных.

public interface MainView {
    void onDownloadApps(List<AppInfo> mAppsList, GetIconThread<AppsAdapter.AppViewHolder> getIconThread, int currentList);

    PackageManager getPackageManagerFromActivity();

    int getMemoryClassFromActivity();

    void onToastReady(String message);

    void onGoneFooter();

    void onVisibleFooter();

    void onSelectionChanged(List<AppInfo> selectedList);

    void onSelectionClear();

    void onPrepareDeleteDialog(List<AppInfo> selectedList);

    void onUpdatedList(AppInfo ai);

    void onSetToolbarTitle(String title);

    void onReadyActivityStart(Intent intent);

    void onReadyActivityStartForResult(Intent intent);

    String getStringFromResources(int id);

    boolean isPackageInstalled(String packageName);

    View getViewForDialog(int layout);

    void saveCheckedUninstallDialog();
}

Запрос различных объектов, таких как PackageManager, происходит на уровне View потому, что Presenter не должен ничего знать о контексте приложения и тем более хранить его у себя. Некоторые разработчики для этих целей советуют хранить статический контекст в отдельном классе Application и запрашивать его оттуда, однако анализатор кода Android Studio настоятельно не рекомендует использовать такой способ, поскольку он приводит к утечкам памяти.

Перенеся логику работы приложения и оставив только пользовательский интерфейс, мы разгрузили код главной активности, сделав его более удобным и читаемым. Итоговый класс MainActivity после преобразований выглядит следующим образом:

public class MainActivity extends AppCompatActivity
        implements NavigationView.OnNavigationItemSelectedListener,
        AppsAdapter.ClickListener,
        MainView {
    private int REQUEST_UNINSTALL = 1;
    private SearchView mSearchView;
    private AppsAdapter mAppsAdapter;
    private LinearLayout footer;
    private ProgressBar progressBar;
    private RecyclerView recyclerView;
    private MainPresenter mainPresenter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        progressBar = findViewById(R.id.progress_spinner);
        recyclerView = findViewById(R.id.recyclerView);

        mainPresenter = new MainPresenterImpl(new AppsInteractorImpl());
        mainPresenter.attachView(this);
        mainPresenter.downloadApps(new GetAppsTask(this));

        DrawerLayout drawer = findViewById(R.id.drawer_layout);
        if (drawer != null) {
            ActionBarDrawerToggle toggle =
                    new ActionBarDrawerToggle(this, drawer, toolbar, R.string.navigation_drawer_open,
                            R.string.navigation_drawer_close);
            drawer.addDrawerListener(toggle);
            toggle.syncState();
        }
        NavigationView navigationView = findViewById(R.id.nav_view);
        navigationView.setNavigationItemSelectedListener(this);

        footer = findViewById(R.id.footer);
        Button delete = findViewById(R.id.delete);
        delete.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mainPresenter.deleteApp();
            }
        });
        Button clear = findViewById(R.id.clear);
        clear.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mainPresenter.clearSelection();
            }
        });
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.main, menu);
        mSearchView = (SearchView) MenuItemCompat.getActionView(menu.findItem(R.id.action_search));
        mSearchView.setQueryHint(getString(R.string.search_hint));
        mSearchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
            @Override
            public boolean onQueryTextSubmit(String query) {
                search(query);
                return true;
            }

            @Override
            public boolean onQueryTextChange(String query) {
                search(query);
                return true;
            }
        });
        return true;
    }

    @Override
    public void onBackPressed() {
        DrawerLayout drawer = findViewById(R.id.drawer_layout);
        if (drawer != null) {
            if (drawer.isDrawerOpen(GravityCompat.START)) {
                drawer.closeDrawer(GravityCompat.START);
            } else {
                if (footer.getVisibility() == View.VISIBLE) {
                    mainPresenter.clearSelection();
                } else {
                    super.onBackPressed();
                }
            }
        } else {
            if (footer.getVisibility() == View.VISIBLE) {
                mainPresenter.clearSelection();
            } else {
                super.onBackPressed();
            }
        }
    }

    @Override
    public boolean onNavigationItemSelected(@NonNull MenuItem item) {
        // Handle navigation view item clicks here.
        int id = item.getItemId();
        mainPresenter.clearSelection();
        if (id == R.id.nav_installed) {
            mainPresenter.changeList(R.string.menu_installed);
        } else if (id == R.id.nav_system) {
            mainPresenter.changeList(R.string.menu_system);
            checkRootDialog();
        } else if (id == R.id.nav_settings) {
            mainPresenter.onSettingsClick();
        } else if (id == R.id.nav_wiki) {
            mainPresenter.onWikiClick();
        } else if (id == R.id.nav_share) {
            mainPresenter.onShareClick(ShareCompat.IntentBuilder.from(this));
        } else if (id == R.id.nav_send) {
            mainPresenter.onSendClick(ShareCompat.IntentBuilder.from(this));
        } else if (id == R.id.nav_apps) {
            mainPresenter.onMarketClick();
        }
        DrawerLayout drawer = findViewById(R.id.drawer_layout);
        if (drawer != null) drawer.closeDrawer(GravityCompat.START);
        return true;
    }

    @Override
    public void onDownloadApps(List<AppInfo> mAppsList, GetIconThread<AppsAdapter.AppViewHolder> getIconThread, int currentList) {
        setAdapter(getIconThread);
        for (AppInfo app : mAppsList) {
            mAppsAdapter.addApp(app);
        }

        mAppsAdapter.notifyDataSetChanged();
    }

    @Override
    public void onSetToolbarTitle(String title) {
        setTitle(title);
    }

    @Override
    public boolean isPackageInstalled(String packageName) {
        return (Tools.isPackageInstalled(this, packageName));
    }

    @Override
    public void onSelectionClear() {
        footer.setVisibility(View.GONE);
        mAppsAdapter.clearSelection();
    }

    @Override
    public void onToastReady(String message) {
        Toast.makeText(this, message, Toast.LENGTH_LONG).show();
    }

    @Override
    public int getMemoryClassFromActivity() {
        return ((ActivityManager) getApplicationContext().getSystemService(Context.ACTIVITY_SERVICE)).getMemoryClass();
    }

    @Override
    public PackageManager getPackageManagerFromActivity() {
        return getPackageManager();
    }

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (requestCode == REQUEST_UNINSTALL) {
            // 0 means success, other means failed.
            Log.d("TAG", "got result of uninstall: " + resultCode);
            mainPresenter.updateAppList();
        }
    }

    @Override
    public void onReadyActivityStart(Intent intent) {
        startActivity(intent);
    }

    @Override
    public void onReadyActivityStartForResult(Intent intent) {
        startActivityForResult(intent, REQUEST_UNINSTALL);
    }

    @Override
    public void onUpdatedList(AppInfo ai) {
        mAppsAdapter.remove(ai);
    }

    @Override
    public void onPrepareDeleteDialog(List<AppInfo> selectedList) {
        progressBar.setVisibility(View.VISIBLE);
        mSearchView.setVisibility(View.GONE);
        for (AppInfo ai : selectedList) {
            if (Tools.m521a(ai.publicSourceDir)) mAppsAdapter.remove(ai);
        }
        progressBar.setVisibility(View.GONE);
        mSearchView.setVisibility(View.VISIBLE);
        mainPresenter.updateAppList();
        if (SP.getBoolean(this, SHOW_UNINSTALL_DIALOG, true)) {
            mainPresenter.showUninstallDialog(new AlertDialog.Builder(this));
        }
    }

    @Override
    public String getStringFromResources(int id) {
        return getString(id);
    }

    @Override
    public View getViewForDialog(int layout) {
        LayoutInflater inflater = this.getLayoutInflater();
        return inflater.inflate(layout, null);
    }

    @Override
    public void saveCheckedUninstallDialog() {
        SP.setBoolean(MainActivity.this, SHOW_UNINSTALL_DIALOG, false);
    }

    @Override
    public void onItemClicked(AppInfo ai) {
        mainPresenter.addToSelected(ai);
    }

    @Override
    public void onGoneFooter() {
        footer.setVisibility(View.GONE);
    }

    @Override
    public void onVisibleFooter() {
        footer.setVisibility(View.VISIBLE);
    }

    @Override
    public void onSelectionChanged(List<AppInfo> selectedList) {
        mAppsAdapter.updateSelected(selectedList);
    }

    @Override
    protected void onStop() {
        mainPresenter.clearQueue();
        super.onStop();
    }

    @Override
    public void onDestroy() {
        mainPresenter.detachView();
        super.onDestroy();
    }

    private void checkRootDialog() {
        if (!Tools.checkRooted()) {
            mainPresenter.prepareRootDialog(new AlertDialog.Builder(this));
        }
    }

    public void setAdapter(GetIconThread<AppsAdapter.AppViewHolder> getIconThread) {
        mAppsAdapter = new AppsAdapter(this, getIconThread);
        recyclerView.setLayoutManager(new LinearLayoutManager(this));
        recyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));
        recyclerView.setAdapter(mAppsAdapter);
    }

    private void search(String query) {
        if (mAppsAdapter != null) mAppsAdapter.filter(query);
    }
}

После всех проделанных операций мы разделили код на три уровня, каждый из которых отвечает выполняет определённые задачи. Подводя итоги, можно сделать следующие выводы:

  • MainActivity не знает ничего об уровне Model;
  • MainActivity не заботится о результате запроса от Presenter;
  • Логика callback-ов остаётся в Presenter. Поэтому если вам захочется в будущем её изменить, вам не нужно будет затрагивать View.

Таким образом, используя в своих приложениях паттерн MVP, мы можем сделать разработку намного проще и понятнее.

Исходный код приложения вы можете посмотреть на GitHub, перейдя по следующей ссылке. Само приложение «Менеджер системных приложений» доступно в Google Play здесь.

Нашли ошибку в тексте?

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *