Android開發之MVP模式的簡單實現

前言

MVP的相關文章在剛哥、郭神的公眾號看過挺多的,對于一個剛開始學MVP的初學者來說,還是一臉懵逼,要自己獨立去寫一個有一種無從下手的感覺。早段時間在簡書上看到一篇轉載的文章,里面有一段寫的很好:MVP 把 Activity 中的 UI 邏輯抽象成 View 接口,把業務邏輯抽象成 Presenter 接口,Model 類還是原來的 Model博客地址)。文章所講述內容是對一個基礎MVP的實現,并沒有通過各種基類來達到解耦封裝。

MVC和MVP

全名Model View Contorller,用于隔離業務邏輯、數據、界面展示,在更改ui界面時不需要對業務邏輯進行代碼變更。
優點:便于理解,開發快速,一定程度隔離業務與ui的耦合度。
缺點:業務復雜有可能導致controller爆炸,不利于維護。

MVC.png

MVP全名Mode View Presenter,Presenter處理邏輯業務,Model提供數據,View更新展示界面。完全隔離界面顯示與業務邏輯,不論更改界面或者更改業務邏輯均為單方面更改。
優點:完全隔離業務邏輯與ui顯示,便與迭代維護測試。
缺點:開發效率低下(時間上),接口類過多。

MVP.png

接口

基礎的MVP開發需要編寫一定的接口,View層和Model層越復雜,接口類的定義就越多,因此,對接口的理解和運用對MVP的理解很重要。

接口是對行為的抽象,簡單地理解就是這個對象會做什么,就是所說的方法,從某種意義上理解,可以把接口看作是包含某一類對象的行為的一個集合,比如動物“吃”的行為,雞和貓是不一樣的。另外,接口有一個很重要的特性,它不能實例化,只能通過它的實現類來實例化對象,我們可以使用接口來聲明一個變量,但只能由實現類來初始化變量,將它實例化,因此,接口可以實現回調的功能。

那么,我們在View層(Model層)定義好接口,在Presenter層使用接口聲明變量,在它的構造方法中,通過其實現類來初始化接口變量,至于實現類如何實現,在哪實現就不是Presenter層所關心的,它只要拿到View層和Model層的引用并且處理好它們之間的業務邏輯就可以了。

對于Model層,我們可以只把實現的方法暴露出去,其它的實現細節可以隱藏掉,體現了Java的封裝性。

實現過程

先看下這個模塊(一個小作品中的一個模塊)的項目結構,它是一個用戶登錄的場景:

項目結構.png

接口ILoginView負責彈出Toast,以及更新UI,比如登錄密碼錯誤的時候。接口IUserLogin負責登錄,而接口IUserLoginListener負責監聽登錄狀態,成功還是失敗。LoginPresenter負責處理好View層和Model層的業務邏輯。LoginActivity實現了ILoginView,它只需做一些變量的初始化,調用api,更新UI(在Activity下很容易實現)的工作就可以了,代碼很簡潔。UserLoginModel實現了IUserLogin,負責處理真正的登錄業務。

那么,下面就直接上代碼了,先看Model層的:

// 接口-IUserLogin
public interface IUserLogin {

    void login(String account, String password);

}

// 接口-IUserLoginListener
public interface IUserLoginListener {

    void success();

    void failed();

    // 請求登錄過程中出現的錯誤,如密碼錯誤
    void error(int type);

}

// 實現類-UserLoginModel
public class UserLoginModel implements IUserLogin {

    private IUserLoginListener loginListener;
    private MyHandler handler;

    public UserLoginModel(IUserLoginListener loginListener) {
        this.loginListener = loginListener;
        handler = new MyHandler();
    }

    @Override
    public void login(String account, String password) {
        if (account.length() == 0 || password.length() == 0) {
            loginListener.error(Constant.TYPE_ACCOUNT_PASSWORD_ERROR_NULL);
            return;
        }

        OkHttpClient client = new OkHttpClient();

        FormBody.Builder formBody = new FormBody.Builder();
        formBody.add("account", account);
        formBody.add("password", password);

        Request request = new Request.Builder()
                .url(Constant.RESOURCE_URL + "LoginServlet")
                .post(formBody.build())
                .build();

        client.newCall(request).enqueue(new LoginCallBack());
    }

    private class LoginCallBack implements Callback {
        @Override
        public void onFailure(@NotNull Call call, @NotNull IOException e) {
            loginListener.failed();
            e.printStackTrace();
        }

        @Override
        public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException {
            if (response.isSuccessful()) {
                String result = response.body().string();
                Log.d(Constant.TAG, "result:" + result);
                
                // 不能再子線程更新UI
                Message message = handler.obtainMessage();
                message.obj = result;
                handler.sendMessage(message);
            }
        }
    }

    private class MyHandler extends Handler {
        @Override
        public void handleMessage(Message msg) {
            String result = (String) msg.obj;
            if ("success".equals(result)) {
                loginListener.success();
            } else if ("not_exist".equals(result)) {
                loginListener.error(Constant.TYPE_ACCOUNT_NOT_EXIST);
            } else if ("error_password".equals(result)) {
                loginListener.error(Constant.TYPE_PASSWORD_ERROR);
            }
        }
    }

}

接著是View層:

// 接口-ILoginView
public interface ILoginView {

    void loginSuccess();

    void loginFailed();

    // 賬號出現錯誤
    void updateAccountView(String info);

    // 密碼出現錯誤
    void updatePasswordView(String info);

    // 根據不同的類型清空輸入框
    void clear(int type);

    // 信息驗證不通過時
    void checkout();

    void updateView();

    // 銷毀LoginActivity
    void destroyActivity();

}

// LoginActivity
public class LoginActivity extends AppCompatActivity implements View.OnClickListener, ILoginView {

    private TextInputLayout tilAccount;
    private TextInputLayout tilPassword;
    private TextInputEditText tieAccount;
    private TextInputEditText tiePassword;
    private MaterialButton btnLogin;
    private MaterialButton btnRegisterActivity;

    private LoginPresenter presenter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_login);

        presenter = new LoginPresenter(this, this);

        String a = RecordInfoState.getAccount(this);
        if (!"account".equals(a) && a != null) {
            startActivity(new Intent(this, MainActivity.class));
            finish();
        }

        init();
    }

    private void init() {
        tilAccount = findViewById(R.id.til_account);
        tilPassword = findViewById(R.id.til_password);
        tieAccount = findViewById(R.id.tie_account);
        tiePassword = findViewById(R.id.tie_password);

        btnLogin = findViewById(R.id.btn_login);
        btnRegisterActivity = findViewById(R.id.btn_register_activity);

        btnLogin.setOnClickListener(this);
        btnRegisterActivity.setOnClickListener(this);
    }

    @Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.btn_login:
                String account = tieAccount.getText().toString();
                String password = tiePassword.getText().toString();
                presenter.doLogin(account, password);
                break;
            case R.id.btn_register_activity:
                startActivity(new Intent(LoginActivity.this, RegisterActivity.class));
                finish();
                break;
            default:
                break;
        }
    }

    @Override
    public void loginSuccess() {
        Toast.makeText(LoginActivity.this, "登錄成功", Toast.LENGTH_SHORT).show();
    }

    @Override
    public void loginFailed() {
        Toast.makeText(LoginActivity.this, "登錄失敗", Toast.LENGTH_SHORT).show();
    }

    @Override
    public void updateAccountView(String info) {
        tilAccount.setErrorEnabled(true);
        tilAccount.setError(info);
    }

    @Override
    public void updatePasswordView(String info) {
        tilPassword.setErrorEnabled(true);
        tilPassword.setError(info);
    }

    @Override
    public void clear(int type) {
        switch (type) {
            case Constant.TYPE_ACCOUNT_NOT_EXIST:
                tieAccount.setText("");
                break;
            case Constant.TYPE_PASSWORD_ERROR:
                tiePassword.setText("");
                break;
            default:
                break;
        }
    }

    @Override
    public void checkout() {
        Toast.makeText(LoginActivity.this, "賬號或密碼不能為空", Toast.LENGTH_SHORT).show();
    }

    @Override
    public void updateView() {
        tilAccount.setErrorEnabled(false);
        tilPassword.setErrorEnabled(false);
    }

    @Override
    public void destroyActivity() {
        this.finish();
    }
}

最后時Presenter層的:

// LoginPresenter
public class LoginPresenter {

    private ILoginView loginView;
    private IUserLogin loginModel;
    private Context context;
    private String accout;

    public LoginPresenter(Context context, ILoginView loginView) {
        this.context = context;
        this.loginView = loginView;
        loginModel = new UserLoginModel(new UserLoginListerImpl());
    }

    public void doLogin(String account, String password) {
        this.accout = account;
        loginView.updateView();
        loginModel.login(account, password);
    }

    private class UserLoginListerImpl implements IUserLoginListener {
        @Override
        public void success() {
            RecordInfoState.logined(context, accout);
            loginView.loginSuccess();
            initPunchCard();
            RecordInfoState.recordPlan(context, "4");
            context.startActivity(new Intent(context, MainActivity.class));
            loginView.destroyActivity();
        }

        // 將保存在遠程數據庫里的打卡日期緩存到本地
        public void initPunchCard() {
            OkHttpClient client = new OkHttpClient();
            FormBody.Builder body = new FormBody.Builder();
            body.add("type", "4");
            body.add("name", RecordInfoState.getAccount(context));

            Request request = new Request.Builder()
                    .url(Constant.RESOURCE_URL + "ResPathServlet")
                    .post(body.build())
                    .build();

            client.newCall(request).enqueue(new Callback() {
                @Override
                public void onFailure(@NotNull Call call, @NotNull IOException e) {

                }

                @Override
                public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException {
                    if (response.isSuccessful()) {
                        String pathJson = response.body().string();

                        try {
                            JSONArray jsonArray = new JSONArray(pathJson);
                            for (int i = 0; i < jsonArray.length(); i++) {
                                JSONObject jsonObject = jsonArray.getJSONObject(i);
                                if (jsonObject != null) {
                                    RecordInfoState.recordPunchCard(context, jsonObject.getString("date"));
                                }
                            }
                        } catch (JSONException e) {
                            e.printStackTrace();
                        }
                    }
                }
            });
        }

        @Override
        public void failed() {
            loginView.loginFailed();
        }

        @Override
        public void error(int type) {
            switch (type) {
                case Constant.TYPE_ACCOUNT_PASSWORD_ERROR_NULL:
                    loginView.checkout();
                    break;
                case Constant.TYPE_ACCOUNT_NOT_EXIST:
                    loginView.updateAccountView("賬號不存在!");
                    loginView.clear(Constant.TYPE_ACCOUNT_NOT_EXIST);
                    break;
                case Constant.TYPE_PASSWORD_ERROR:
                    loginView.updatePasswordView("密碼錯誤!");
                    loginView.clear(Constant.TYPE_PASSWORD_ERROR);
                    break;
                default:
                    break;
            }
        }
    }

}

總結

一個簡單的MVP模式就實現完了,筆者認為對接口的理解和運用時理解MVP的關鍵。通過接口的方式將定義與實現分開,實現View層與Model層的解耦。還有要明確不同層之間的工作:

  • View:對應于Activity和XML(在一些特殊的場景下,dialog,fragment也可以充當View),負責View的繪制以及與用戶的交互。
  • Model:依然是實體模型。
  • Presenter:負責完成View與Model間的交互和業務邏輯。
    所以,View做的工作Model不關心,Model做的工作View也不關心,Presenter充當了工作類似現實生活中的中介工作,它使得View與Model可以愉快地溝通。

你只要從View層與Model層需要解耦這個切入點去思考,來不斷解決心中的疑問,比如為什么要通過定義接口來實現?別人為什么這樣寫?如果業務和UI更復雜了應該怎么寫等等。不管思考的過程怎樣(不要一直死磕),但一定要有自己的總結、反思。積累夠了,自然而然就要一種恍然大悟的感覺。

源碼地址

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,797評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,179評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,628評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,642評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,444評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,948評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,040評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,185評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,717評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,794評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,418評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,414評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,750評論 2 370

推薦閱讀更多精彩內容