MVP模式在Android開發中的最佳實務
這篇文章拖了好久了,一直存在草稿箱裡沒有繼續寫,趁今天有空,擼擼完。
回想一下,你剛剛學習Android的時候,總會看到一些書上寫著,Android使用的是MVC模式,Activity就是一個Controller,或許那個時候,你沒有什麼深刻的體會。隨著經驗的積累。你發現,Activity既是Controller,掌管著許許多多的商務邏輯,同時它也作為View的一部分,控制著視圖層的顯示。久而久之,這個Controller便顯得過於重,職責不再那麼單一。
於是,再後來,為了使Activity的職責更加單一,便出現了MVP,MVVM等模式,只能說各有各的優點,沒有誰對誰錯,一個模式有另一個模式不具有的特點,同時也不具備另一個模式具有的特點,架構的選擇永遠是根據業務的複雜程度來進行的。MVC有其特點,就是寫代碼簡單啊,但是其缺點也很明顯,業務複雜起來後,Activity顯得過於龐大不是特別好維護。至於MVVM,個人是十分排斥這種模式的,為什麼呢,在XML中寫資料繫結的代碼顯得有點蛋疼,從而使得xml的職責不是那麼單一,在我看來,xml用來作為View再好不過了,不必摻和其他任何元素進來,這樣顯得“不乾淨”。而MVP呢,我覺得在Android開發中,MVP是一個值得考慮的模式,它既沒有MVVM那樣,在xml中寫資料繫結的代碼,xml依然還是原來的配方,也沒有MVC那樣,擁有一個臃腫的Controller,取而代之的是更加清晰的分層,職責更加單一,當然,優點背後必然有缺點,相信用過MVP的都知道有什麼缺點,那就是介面的定義會暴增。
那麼什麼是MVP模式呢?
M即Model,what to show? 也就是顯示在UI上的資料,至於資料怎麼來,資料庫,網路等等渠道,都是屬於這一層
V即View,how to show?也就是怎麼顯示資料,在Android中,通常是使用xml定義這個view,一般View中會持有Presenter的引用。
P即Presenter,Presenter扮演著中間連絡人的作用,就好比MVC中的Controller,通常來說,Presenetr中一般會持有View和Model的引用。
這三者的聯絡如所示:
那麼問題來了,該如何?MVP模式呢?這裡介紹一個開源庫Mosby,github地址https://github.com/sockeqwe/mosby
本篇文章不對該庫的具體實現作分析,如果對實現感興趣的可以閱讀源碼,畢竟源碼之前,了無秘密。在使用前,先加入對該庫的依賴
dependencies { compile 'com.hannesdorfmann.mosby:mvp:2.0.1' compile 'com.hannesdorfmann.mosby:viewstate:2.0.1'}
現在假設我們實現一個登陸功能,原來的MVC方式就是先定義好xml,然後直接在Activity中書寫各種商務邏輯,導致Activity越來越龐大,而使用了MVP之後,Activity會顯得十分乾淨。
XML的定義這裡就不再貼了,兩個輸入框(帳號和密碼),一個登陸按鈕。
首先,我們需要一個與伺服器互動的介面,為了簡單起見,我們在本地進行類比,如果帳號密碼都是admin,則登陸成功,如果帳號密碼都是server,其他情況都返回帳號或密碼錯誤。理論上,這個需要在子線程中發起請求,再通過UI線程回調,這一步也省略,直接在主線程中判斷並回調,由於是本地類比,不會產生任何卡頓,實際使用時需嚴格按照子線程請求主線程回調。
public interface Listener { void onSuccess(T t); void onFailure(int code);}
public class LoginApi { public static void login(String username, String password, Listener listener) { if (username.equals("admin") && password.equals("admin")) { listener.onSuccess(null); } else if (username.equals("server") && password.equals("server")) { listener.onFailure(LoginView.SERVER_ERROR); } else { listener.onFailure(LoginView.USERNAME_OR_PASSWORD_ERROR); } }}
商務邏輯的介面定義好了,這個LoginApi可以認為是Model層,接下來我們需要定義和Login相關的View,Presenter。
首先定義一個LoginView介面繼承MvpView介面,由於登入的介面有兩種情況,一種是登入成功,一種是登入失敗,而登入失敗的情況又有多種,於是需要通過一個狀態代碼進行區分,於是LoginView中的介面就產生了。這裡我們直接將各種錯誤狀態定義在了LoginView中,實際使用時建議定義在一個常量類中進行統一管理。
public interface LoginView extends MvpView { public static final int USERNAME_OR_PASSWORD_EMPTY = 0x01; public static final int USERNAME_OR_PASSWORD_ERROR = 0x02; public static final int SERVER_ERROR = 0x03; void onLoginSuccess(); void onLoginFailure(int code);}
然後定義一個LoginPresenter類繼承MvpBasePresenter,泛型參數是LoginView,在裡面調用LoginApi的介面並將介面返回。
public class LoginPresenter extends MvpBasePresenter { public void login(final String username, final String password) { if (username == null || username.equals("")) { LoginView view = getView(); if (view != null) { view.onLoginFailure(LoginView.USERNAME_OR_PASSWORD_EMPTY); return; } } else if (password == null || password.equals("")) { LoginView view = getView(); if (view != null) { view.onLoginFailure(LoginView.USERNAME_OR_PASSWORD_EMPTY); return; } } Listener listener = new Listener() { @Override public void onSuccess(String str) { LoginView view = getView(); if (view != null) { view.onLoginSuccess(); } } @Override public void onFailure(int code) { if (code == LoginView.USERNAME_OR_PASSWORD_ERROR) { LoginView view = getView(); if (view != null) { view.onLoginFailure(LoginView.USERNAME_OR_PASSWORD_ERROR); } } else { LoginView view = getView(); if (view != null) { view.onLoginFailure(LoginView.SERVER_ERROR); } } } }; LoginApi.login(username, password, listener); }}
最後便是讓Activity實現LoginView介面,實現LoginView中定義的介面,此外,還需要繼承MvpActivity,泛型參數是LoginView和LoginPresenter,並實現抽象方法createPresenter()返回LoginPresenter,而在LoginView中定義的兩個介面onLoginSuccess和onLoginFailure中,全都是UI相關的代碼,整個Activity中不再有商務邏輯的代碼,職責也就單一了。
public class LoginActivity extends MvpActivity implements View.OnClickListener, LoginView { private EditText etAccount; private EditText etPassword; private Button btnLogin; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); etAccount = (EditText) findViewById(R.id.accout); etPassword = (EditText) findViewById(R.id.password); btnLogin = (Button) findViewById(R.id.login); btnLogin.setOnClickListener(this); } @NonNull @Override public LoginPresenter createPresenter() { return new LoginPresenter(); } @Override public void onClick(View v) { switch (v.getId()) { case R.id.login: onLogin(); break; } } private void onLogin() { String username = etAccount.getText().toString(); String passowrd = etPassword.getText().toString(); getPresenter().login(username, passowrd); } @Override public void onLoginSuccess() { Toast.makeText(this, "登陸成功", Toast.LENGTH_SHORT).show(); } @Override public void onLoginFailure(int code) { switch (code) { case LoginView.USERNAME_OR_PASSWORD_EMPTY: Toast.makeText(this, "帳號或密碼不可為空", Toast.LENGTH_SHORT).show(); break; case LoginView.USERNAME_OR_PASSWORD_ERROR: Toast.makeText(this, "帳號或密碼錯誤", Toast.LENGTH_SHORT).show(); break; case LoginView.SERVER_ERROR: Toast.makeText(this, "伺服器錯誤", Toast.LENGTH_SHORT).show(); break; } }}
特別需要注意的是,在Presenter中引用View時,一定要判斷是否非空,因為這個View是WeakReference弱引用,不進行判斷的話會產生null 指標異常。這是這個架構不好的地方,需要多次重複判空。
以上是這個架構最基礎的用法,實際使用時我們一般不會這麼直接使用它的類,一般來說,我們會定義各種Base類,比如BaseView,BasePresenter,BaseActivity,BaseFragment;從而將各種公用的方法都放著裡面,減少冗餘。如果你要引用這個架構,實際使用時稍微注意一下這個問題就可以了。
此外,Mosby還有一個LCE模組,什麼是LCE模組呢,其實就是Loading-Content-Error的全稱,主要用於資料的載入,顯示燈作用,它體現在一個MvpLceView這個介面上以及具體的實現MvpLceActivity和MvpLceFragment上,該介面的定義如下。
public interface MvpLceView extends MvpView { /** * Display a loading view while loading data in background. * The loading view must have the id = R.id.loadingView * * @param pullToRefresh true, if pull-to-refresh has been invoked loading. */ public void showLoading(boolean pullToRefresh); /** * Show the content view. * * The content view must have the id = R.id.contentView */ public void showContent(); /** * Show the error view. * The error view must be a TextView with the id = R.id.errorView * * @param e The Throwable that has caused this error * @param pullToRefresh true, if the exception was thrown during pull-to-refresh, otherwise * false. */ public void showError(Throwable e, boolean pullToRefresh); /** * The data that should be displayed with {@link #showContent()} */ public void setData(M data); /** * Load the data. Typically invokes the presenter method to load the desired data. * * Should not be called from presenter to prevent infinity loops. The method is declared * in * the views interface to add support for view state easily. *
* * @param pullToRefresh true, if triggered by a pull to refresh. Otherwise false. */ public void loadData(boolean pullToRefresh);}
該介面中定義了5個方法,
showLoading 用於顯示載入資料時的動畫,比如進度條 showError 用於顯示載入資料失敗的內容 setData 當資料載入成功時,將資料進行賦值,在調用showContent之前進行調用 loadData 載入資料,這個方法一般是放著Activity或者Fragment中進行調用的 showContent 資料載入成功時顯示
除此之外,我們還要使用MvpLceActivity或者MvpLceFragment,還要在xml中定義相關的View,比如errorView,contenView等等。
現在我們來實踐一下,以顯示一個新聞列表為例。
首先定義布局,在布局中需要聲明errorView,loadingView,contentView這幾個id
<code class=" hljs xml"><framelayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <!--{cke_protected}{C}%3C!%2D%2D%20Loading%20View%20%2D%2D%3E--> <progressbar android:id="@+id/loadingView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:indeterminate="true"> <!--{cke_protected}{C}%3C!%2D%2D%20Content%20View%20%2D%2D%3E--> <android.support.v4.widget.swiperefreshlayout android:id="@+id/contentView" android:layout_width="match_parent" android:layout_height="match_parent"> <android.support.v7.widget.recyclerview android:id="@+id/recyclerView" android:layout_width="match_parent" android:layout_height="match_parent"> </android.support.v7.widget.recyclerview></android.support.v4.widget.swiperefreshlayout> <!--{cke_protected}{C}%3C!%2D%2D%20Error%20view%20%2D%2D%3E--> <textview android:id="@+id/errorView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:text="error"></textview></progressbar></framelayout></code>
定義實體類,並添加建構函式和getter,setter方法
public class News { private String title; private String desprition; public News(String title, String desprition) { this.title = title; this.desprition = desprition; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getDesprition() { return desprition; } public void setDesprition(String desprition) { this.desprition = desprition; } @Override public String toString() { return "News{" + "title='" + title + '\'' + ", desprition='" + desprition + '\'' + '}'; }}
定義View層介面,空介面,繼承MvpLceView即可
public interface NewsView extends MvpLceView>{}
定義Presenter層,調用Model層方法擷取資料來源,在使用getView之前,一定要調用isViewAttached()方法或者使用getView!=null進行判空。不然極有可能產生null 指標異常,在onSuccess中,調用view層的setData和showContent進行資料的顯示,在onFaliure中則調用showError顯示資料載入失敗。
public class NewsPresenter extends MvpBasePresenter { public void loadNews(final boolean pullToRefresh) { if (isViewAttached()) { getView().showLoading(pullToRefresh); } Listener> listener=new Listener>() { @Override public void onSuccess(List news) { if (isViewAttached()) { getView().setData(news); getView().showContent(); } } @Override public void onFailure(int code) { if (isViewAttached()) { getView().showError(new Exception("msg:"+code), pullToRefresh); } } }; NewsApi.loadNews(pullToRefresh,listener); }}
編寫介面方法,這裡同樣採用類比,不過為了有載入動畫等效果的顯示,這裡在子線程中進行類比,之後切回主線程,並且,為了達到伺服器錯誤的類比效果,使用了一個隨機數,當隨機數為奇數時則返回擷取資料失敗的情境
public class NewsApi { private static Handler handler = new Handler(Looper.getMainLooper()); private static Random random = new Random(); public static void loadNews(final boolean pullToRefresh, final Listener> listener) { new Thread(new Runnable() { @Override public void run() { final List list = new ArrayList(); News news1 = new News("標題1", "描述描述描述描述描述描述描述描述描述描述描述描述1"); News news2 = new News("標題2", "描述描述描述描述描述描述描述描述描述描述描述描述2"); News news3 = new News("標題3", "描述描述描述描述描述描述描述描述描述描述描述描述3"); News news4 = new News("標題4", "描述描述描述描述描述描述描述描述描述描述描述描述4"); News news5 = new News("標題5", "描述描述描述描述描述描述描述描述描述描述描述描述5"); News news6 = new News("標題6", "描述描述描述描述描述描述描述描述描述描述描述描述6"); list.add(news1); list.add(news2); list.add(news3); list.add(news4); list.add(news5); if (pullToRefresh) { list.add(news6); } try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } handler.post(new Runnable() { @Override public void run() { if (listener != null) { listener.onFailure(1); int i = random.nextInt(100); if (i % 2 == 0) { listener.onSuccess(list); } else { listener.onFailure(1000); } } } }); } }).start(); }}
對應的Activity則是繼承了MvpLceActivity,重寫抽象方法,理論上來說showContent和showError是不需要重寫的,但是這裡使用了SwipeRefreshLayout,需要將載入的那個圓圈給隱藏掉,需要重寫這兩個方法,調用setRefreshing設為false;getErrorMessage方法返回的字串類型便是用來顯示在errorView上的,當不是下拉重新整理時,則直接顯示在errorView上,否則,使用Toast進行彈出。setData方法就是資料擷取成功後對資料來源進行使用,比如設定到adapter並通知數據源改變。loadData方法則調用presenter中的方法進行載入即可
public class NewsActivity extends MvpLceActivity, NewsView, NewsPresenter> implements NewsView, SwipeRefreshLayout.OnRefreshListener { private RecyclerView recyclerView; private NewsAdapter adapter; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_news); adapter = new NewsAdapter(); contentView.setOnRefreshListener(this); recyclerView = (RecyclerView) findViewById(R.id.recyclerView); recyclerView.setLayoutManager(new LinearLayoutManager(this)); recyclerView.setAdapter(adapter); loadData(false); } @NonNull @Override public NewsPresenter createPresenter() { return new NewsPresenter(); } @Override public void showContent() { super.showContent(); contentView.setRefreshing(false); } @Override public void showError(Throwable e, boolean pullToRefresh) { super.showError(e, pullToRefresh); contentView.setRefreshing(false); } @Override protected String getErrorMessage(Throwable e, boolean pullToRefresh) { return "發生了錯誤"; } @Override public void setData(List data) { adapter.setNews(data); adapter.notifyDataSetChanged(); } @Override public void loadData(boolean pullToRefresh) { presenter.loadNews(pullToRefresh); } @Override public void onRefresh() { contentView.setRefreshing(true); loadData(true); }}
adapter就不貼了,比較簡單。
最終的效果如下
源碼。
最後,貼上全部代碼。