Android MVP模式深入实践探索(二)

上一篇总结了MVP模式的基础结构,这一篇主要总结在MVP模式中该如何进行分类,即哪些属于View层,哪些该放在Presenter层,哪些该放在Modle层,如何从一堆杂乱的UI代码中将相关代码提取到MVP对应的层次当中。

先从View层说起,对于View层其实是最好划分的,首先想到的是Activity、Fragment、Dialog等系统的组件,还有加载我们的布局文件,进行findView操作,对TextView、EditText、ImageView、LinearLayout等UI控件进行数据的赋值或者数据的提取,以及属性的设置等等。这些是最基本的一些日常操作,按照老的思维我们一般会想当然的认为与这些操作相关的都算View层的操作(当然不是啦)。

在使用这些控件的过程中,可能会涉及到数据的获取和存储,如最常见的就是网络请求,本地sharepreference的数据读取等,甚至会有一些根据业务的逻辑判断去设置某个UI控件的值或者对UI控件进行显示隐藏等等,因为一般的但凡有点业务交互的项目不可能是纯UI的操作。这就会导致一个问题:UI和逻辑的混合体代码,这一部分的代码通常会直接存在于比如你的Activity的onCreate方法当中,或者是Fragment的生命周期方法当中。

按照MVP的模式,我们的主要目标就是要将纯UI操作的代码和纯UI以外的业务代码分隔开来,这看上去感觉有点像“前后端分离”的意思,当然跟Web端的操作还是不一样的。我们更像是在做代码的切割操作。如果你目前正在做公司的项目重构,那么可能你的很大一部分精力将花费在如何将这两类代码合理的解耦拆分开来。

直接来看一段示例代码:

public class MainActivity extends Activity implements View.OnClickListener {

    private TextView mUserNameText;
    private TextView mSexText;
    private LinearLayout mAdsLayout;
    private LinearLayout mNewsLayout;
    private TextView mLoginText;
    private LinearLayout mMyAttentionLayout;
    private LinearLayout mMyCommentLayout;
    private ImageView mVipImageView;

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

    private void initView() {
        mUserNameText = (TextView) findViewById(R.id.tv_user_name);
        mSexText = (TextView) findViewById(R.id.tv_sex);
        mLoginText = (TextView) findViewById(R.id.tv_login);
        mLoginText.setOnClickListener(this);
        mMyAttentionLayout = (LinearLayout) findViewById(R.id.ll_my_attention);
        mMyCommentLayout = (LinearLayout) findViewById(R.id.ll_my_comment);

        User savedUser = LocalDataManager.getSavedUser();
        if (savedUser != null) {
            if (!TextUtils.isEmpty(savedUser.getName())) {
                mUserNameText.setText(savedUser.getName());
            } else {
                mUserNameText.setText("匿名用户");
            }
            if (savedUser.getSex() == 1) {
                mSexText.setText("男");
            } else if (savedUser.getSex() == 2) {
                mSexText.setText("女");
            }
        }

        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
        boolean isLogin = preferences.getBoolean("isLogin", false);
        if (isLogin) {
            mLoginText.setVisibility(View.GONE);
        } else {
            mLoginText.setVisibility(View.VISIBLE);
        }

        mVipImageView = (ImageView) findViewById(R.id.iv_vip);
        if (getIntent().getBooleanExtra("isShowVip", false)) {
            mVipImageView.setVisibility(View.VISIBLE);
            mVipImageView.setImageResource(R.drawable.ic_vip);
        } else {
            mVipImageView.setVisibility(View.GONE);
        }
    }

    @Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.tv_login:
                SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
                String userName = preferences.getString("userName", "");
                String userPassword = preferences.getString("userPassword", "");
                if (!TextUtils.isEmpty(userName) && !TextUtils.isEmpty(userPassword)) {
                    sendLoginRequest(userName, userPassword);
                }
                break;
            default:
                break;
        }
    }

    private void sendLoginRequest(String userName, String password) {
        String url = "http://xx.xxx.xxx";
        StringHashMap requestParams = new StringHashMap();
        requestParams.put("userName", userName);
        requestParams.put("password", password);
        HttpDataManager.post(url, requestParams, new HttpCallback() {
            @Override
            public void onSuccess(String result, Object tag) {
                mMyAttentionLayout.setVisibility(View.GONE);
                mMyAttentionLayout.setVisibility(View.GONE);
                LoginResultBean loginResult = JsonUtils.jsonToObject(result, LoginResultBean.class);
                if (loginResult != null) {
                    if (loginResult.getErrCode() == 200) {
                        mMyAttentionLayout.setVisibility(View.VISIBLE);
                        mMyAttentionLayout.setVisibility(View.VISIBLE);
                    } else {
                        mLoginText.setText("登录失败");
                    }
                } else {
                    mLoginText.setText("登录失败");
                }
            }

            @Override
            public void onError(String result, Object tag) {
                mLoginText.setText("登录失败");
            }
        }, 100);
    }
}

这段代码是我随意编写的,本身不具有任何的具体含义,只是为了表达一些情况,布局文件就不贴了。这段代码中有大量由数据驱动的UI状态的改变,包括显隐、内容设置等,这些数据来自不同的源:本地数据库、SharedPreferences、网络、页面传值等,这些都是跟UI无关的操作,也就是我们需要从View层提取的那部分代码,上面的代码比较简单,你应该很容易就能分清哪些是纯UI的操作:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
我们现在要做的很明确了,就是把框起来的代码留在当前的MainActivity中,而把剩余的代码全部挪到Presenter当中:

第一步提取变化的View接口:

public interface IMainView {
    void setUserNameText(String str);
    void setSexText(String str);
    void setLoginText(String str);
    void setLoginTextVisibility(boolean visibility);
    void setIsShowVipImage(boolean isShow);
    void setMyAttentionLayoutVisibility(boolean visibility);
    void setMyCommentLayoutVisibility(boolean visibility);
    Context getContext();
}

第二步业务相关的逻辑移到Presenter中:

public class MainPresenter {
    private IMainView mMainView;
    private Bundle arguments;

    public MainPresenter(IMainView mainView) {
        this.mMainView = mainView;
    }

    public void setArguments(Bundle arguments) {
        this.arguments = arguments;
    }

    public Bundle getArguments() {
        return arguments;
    }

    public void init() {
        User savedUser = LocalDataManager.getSavedUser();
        if (savedUser != null) {
            if (!TextUtils.isEmpty(savedUser.getName())) {
                mMainView.setUserNameText(savedUser.getName());
            } else {
                mMainView.setUserNameText("匿名用户");
            }
            if (savedUser.getSex() == 1) {
                mMainView.setSexText("男");
            } else if (savedUser.getSex() == 2) {
                mMainView.setSexText("女");
            }
        }

        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(mMainView.getContext());
        boolean isLogin = preferences.getBoolean("isLogin", false);
        if (isLogin) {
            mMainView.setLoginTextVisibility(false);
        } else {
            mMainView.setLoginTextVisibility(true);
        }

        if (getArguments().getBoolean("isShowVip", false)) {
            mMainView.setIsShowVipImage(true);
        } else {
            mMainView.setIsShowVipImage(false);
        }
    }

    public void sendLoginRequest() {
        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(mMainView.getContext());
        String userName = preferences.getString("userName", "");
        String userPassword = preferences.getString("userPassword", "");
        if (!TextUtils.isEmpty(userName) && !TextUtils.isEmpty(userPassword)) {
            sendLoginRequest(userName, userPassword);
        }
    }

    private void sendLoginRequest(String userName, String password) {
        String url = "http://xx.xxx.xxx";
        StringHashMap requestParams = new StringHashMap();
        requestParams.put("userName", userName);
        requestParams.put("password", password);
        HttpDataManager.post(url, requestParams, new HttpCallback() {
            @Override
            public void onSuccess(String result, Object tag) {
                mMainView.setMyAttentionLayoutVisibility(false);
                mMainView.setMyCommentLayoutVisibility(false);
                LoginResultBean loginResult = JsonUtils.jsonToObject(result, LoginResultBean.class);
                if (loginResult != null) {
                    if (loginResult.getErrCode() == 200) {
                        mMainView.setMyAttentionLayoutVisibility(true);
                        mMainView.setMyCommentLayoutVisibility(true);
                    } else {
                        mMainView.setLoginText("登录失败");
                    }
                } else {
                    mMainView.setLoginText("登录失败");
                }
            }

            @Override
            public void onError(String result, Object tag) {
                mMainView.setLoginText("登录失败");
            }
        }, 100);
    }
}

第三步MainActivity实现定义的View接口并调用Presenter:

public class MainActivity extends Activity implements View.OnClickListener, IMainView {
    private TextView mUserNameText;
    private TextView mSexText;
    private LinearLayout mAdsLayout;
    private LinearLayout mNewsLayout;
    private TextView mLoginText;
    private LinearLayout mMyAttentionLayout;
    private LinearLayout mMyCommentLayout;
    private ImageView mVipImageView;
    private MainPresenter mMainPresenter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mMainPresenter = new MainPresenter(this);
        mMainPresenter.setArguments(getIntent().getExtras());
        initView();
    }

    private void initView() {
        mUserNameText = (TextView) findViewById(R.id.tv_user_name);
        mSexText = (TextView) findViewById(R.id.tv_sex);
        mLoginText = (TextView) findViewById(R.id.tv_login);
        mLoginText.setOnClickListener(this);
        mMyAttentionLayout = (LinearLayout) findViewById(R.id.ll_my_attention);
        mMyCommentLayout = (LinearLayout) findViewById(R.id.ll_my_comment);
        mVipImageView = (ImageView) findViewById(R.id.iv_vip);

        mMainPresenter.init();
    }

    @Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.tv_login:
                mMainPresenter.sendLoginRequest();
                break;
            default:
                break;
        }
    }

    @Override
    public void setUserNameText(String str) {
        mUserNameText.setText(str);
    }

    @Override
    public void setSexText(String str) {
        mSexText.setText(str);
    }

    @Override
    public void setLoginText(String str) {
        mLoginText.setText(str);
    }

    @Override
    public void setLoginTextVisibility(boolean visibility) {
        mLoginText.setVisibility(visibility ? View.VISIBLE: View.GONE);
    }

    @Override
    public void setIsShowVipImage(boolean isShowVipImage) {
        if (isShowVipImage) {
            mVipImageView.setVisibility(View.VISIBLE);
            mVipImageView.setImageResource(R.drawable.ic_vip);
        } else {
            mVipImageView.setVisibility(View.GONE);
        }
    }

    @Override
    public void setMyAttentionLayoutVisibility(boolean visibility) {
        mMyAttentionLayout.setVisibility(visibility ? View.VISIBLE: View.GONE);
    }

    @Override
    public void setMyCommentLayoutVisibility(boolean visibility) {
        mMyCommentLayout.setVisibility(visibility ? View.VISIBLE: View.GONE);
    }

    @Override
    public Context getContext() {
        return this;
    }
}

这里我们需要定义的跟View交互的接口方法数量可能有点多,几乎是你每改一处就要增加一个,然后在Presenter当中去调用,显得很麻烦,但是我们也有方法能够减少这类麻烦(后续文章中会介绍Presenter与View交互的可以避免这些)。

另外,上述代码你可能关心的一个问题是,原来if-else也算是一种逻辑,这是当然的,上面的代码只是为了示例判断都比较简单,实际中可能会比较复杂,比如你的判断条件可能是一个综合判断条件,由多个判断因素共同决定,而这些因素又可能来自不同的数据源,比如你可能需要从本地取一个值然后去&&前一个页面传递过来的值,最后你还得调用一个查询接口从服务器获取一个状态,这都是有可能的。所有涉及这些判断因素的变量可能又会牵扯出一些逻辑处理。那么UI界面的所有if-else判断都是需要提取到Presenter当中吗?当然不是,再来看一个代码:

    @Override
    public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
        setIsShowCommentView(isChecked);
    }
    
    private void setIsShowCommentView(boolean isChecked) {
        if (isChecked) {
            mMyCommentText.setText("AAA");
            mMyCommentLayout.setVisibility(View.VISIBLE);
        } else if (mLoginText.getVisibility() == View.VISIBLE) {
            mMyCommentText.setText("BBB");
            mMyCommentLayout.setVisibility(View.GONE);
        }
    }

	@Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.tv_my_attention:
                if (mMyAttentionText.getText().toString().equals(getString(R.string.attention))) {
                    mMyAttentionText.setText(R.string.unattention);
                } else if (mMyAttentionText.getText().toString().equals(getString(R.string.unattention))) {
                    mMyAttentionText.setText(R.string.attention);
                }
                break;
            default:
                break;
        }
    }

这个代码里面已经都是纯UI的操作,所以不需要再提取了,如果再增加一些逻辑变量去控制上面的代码,反而又增加了逻辑的复杂性。你会发现这个代码有个特点就是它是由界面中其他控件的状态改变直接导致某个控件的变化,比如点击状态、复选框状态发生变化,需要另一些UI状态对此作出变化。也是UI状态的变化直接导致的UI状态变化,而非业务数据导致的。

所以这里可以总结出只有那些是由业务数据造成的影响而导致的UI变化,导致这种变化的因素我们需要将它从View层剥离出来,挪到Presenter中去。当然,如果你乐意并且时间充足,也可以把所有导致的UI变化的因素全部抽象成逻辑,但是这样会增加时间成本。

前面例子是改变UI的状态,下面看一个提交UI的数据的代码,就用第一篇中登录的例子来修改一下:
在这里插入图片描述
上面代码也是我随意写的,这里除了用户名密码增加了一些参数,而增加的这些参数的特点是跟UI没有任何关系,所以这部分的代码做MVP的改造的话,主要是把绿框中的UI相关的代码留在当前页面,其他全部挪到Presenter当中即可,改造代码这里就不贴了,跟前面的过程类似。

到这里差不多能把View层中跟UI相关或者无关的代码分离开来了, 至少从主观上我们能够做一些区分了,然而前面我们只是简单的将逻辑部分全部移到了Presenter中, 那么Model层呢? 其实如果你的项目比较简单,到这里我觉得就足够了,为什么,因为再分下去,除了增加调用链的长度以外,没有任何好处。但是如果你的项目稍微大一点复杂一点,那么最好将Presenter里的调用再次抽取,也就是我们所谓的Model层,那哪些代码需要放到这一层呢? 主要是数据的存取、转换、过滤操作等相关的代码,比如请求网络回来判断结果值是否成功、json数据解析成Java实体类、本地数据库的查询等。当然在Model层也可以有业务逻辑,不过这部分的逻辑主要跟数据有关系的,比如数据的转换。如果这一层的逻辑较少只是纯粹的读写的话,那么它看起来只不过是将取得的数据直接抛给了Presenter层而已(因为IO读写实际上只需一个工具类即可完成,这个工具类你可以把它看做Model层)。说白了,只是封装的层次深浅和调用链长短的区别了。但是如果涉及数据的业务比较重(如有大量的数据类转换处理),那就必须进行抽取Model层来做。所以这个是要根据实际情况进行取舍的,不一定层次越多越好,维护成本也要考虑进去。

下面将前面例子中提取的MainPresenter中的代码进一步提取,将其中的数据存取的部分提取到Model层,先看一下哪些是可以从Presenter中剥离的:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
其中绿色框起来的部分是可以被放到Model层进行处理,应该是比较显而易见的,其实判断方法很简单,理论上在Presenter中除了直接调用view层接口的,剩下的部分都可以算作是Model层。但是如果你真的按照这个去区分的话,又会有点过分,比如Presenter中的一些if-else判断也是可以挪到Model层处理的,又比如下面这行代码,这个参数是由Activity中传递到Presenter然后在Presenter中获取出来的,挪到Model层有点浪费时间。
在这里插入图片描述
先不管这些细节的,我们先把上面绿色框起来的部分提取到Model层:

先抽取Model接口:

public interface IMainModel {
    void getSavedUser();
    boolean getIsLogin();
    void sendLoginRequest();
}

Model实现类:

public class MainModel implements IMainModel {
    private IMainPresenter mMainPresenter;

    public MainModel(IMainPresenter presenter) {
        mMainPresenter = presenter;
    }

    @Override
    public void getSavedUser() {
        User user = LocalDataManager.getSavedObj(mMainPresenter.getContext(), "User", User.class);
        mMainPresenter.onGetUser(user);
    }

    @Override
    public boolean getIsLogin() {
        return LocalDataManager.getShareBool(mMainPresenter.getContext(), "isLogin");
    }

    @Override
    public void sendLoginRequest() {
        String[] values = LocalDataManager.getShareStrings(
                mMainPresenter.getContext(), "userName", "userPassword");
        String userName = values[0];
        String userPassword = values[1];
        if (!TextUtils.isEmpty(userName) && !TextUtils.isEmpty(userPassword)) {
            String url = "http://xx.xxx.xxx";
            StringHashMap requestParams = new StringHashMap();
            requestParams.put("userName", userName);
            requestParams.put("password", userPassword);
            HttpDataManager.post(url, requestParams, new HttpCallback() {
                @Override
                public void onSuccess(String result, Object tag) {
                    LoginResultBean loginResult = JsonUtils.jsonToObject(result, LoginResultBean.class);
                    if (loginResult != null) {
                        if (loginResult.getErrCode() == 200) {
                            mMainPresenter.onLoginSuccess();
                        } else {
                            mMainPresenter.onLoginFail();
                        }
                    } else {
                        mMainPresenter.onLoginFail();
                    }
                }

                @Override
                public void onError(String result, Object tag) {
                    mMainPresenter.onLoginFail();
                }
            }, 100);
        }
    }
}

其中有一部分纯IO的做成了工具类方法放到了LocalDataManager类中:

public class LocalDataManager {

    public static <T> T getSavedObj(Context context, String key, Class<T> cls) {
        String json = getShareString(context, key);
        T t = JsonUtils.jsonToObject(json, cls);
        return t;
    }

    public static String getShareString(Context context, String key) {
        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
        return preferences.getString(key, "");
    }

    public static boolean getShareBool(Context context, String key) {
        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
        return preferences.getBoolean(key, false);
    }

    public static String[] getShareStrings(Context context, String...keys) {
        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
        String[] res = new String[keys.length];
        for (int i = 0; i < keys.length; i++) {
            res[i] = preferences.getString(keys[i], "");
        }
        return res;
    }
}

然后就是我们的Presenter, 上面的代码中调用了IMainPresenter接口,所以我们要先定义跟Presenter交互的接口IMainPresenter:

public interface IMainPresenter {
    void onGetUser(User user);
    void onLoginSuccess();
    void onLoginFail();
    Context getContext();
}

这几个方法基本都是回传数据,或者回调方法,最后就是MainPresenter实现这个接口并且调用Model类了:

public class MainPresenter implements IMainPresenter {
    private IMainView mMainView;
    private Bundle arguments;
    private IMainModel mMainModel;

    public MainPresenter(IMainView mainView) {
        this.mMainView = mainView;
        mMainModel = new MainModel(this);
    }

    public void setArguments(Bundle arguments) {
        this.arguments = arguments;
    }

    public Bundle getArguments() {
        return arguments;
    }

    public void init() {
        mMainModel.getSavedUser();

        mMainView.setLoginTextVisibility(!mMainModel.getIsLogin());
        mMainView.setIsShowVipImage(getArguments().getBoolean("isShowVip", false));
    }

    @Override
    public void onGetUser(User savedUser) {
        if (savedUser != null) {
            if (!TextUtils.isEmpty(savedUser.getName())) {
                mMainView.setUserNameText(savedUser.getName());
            } else {
                mMainView.setUserNameText("匿名用户");
            }
            if (savedUser.getSex() == 1) {
                mMainView.setSexText("男");
            } else if (savedUser.getSex() == 2) {
                mMainView.setSexText("女");
            }
        }
    }

    public void sendLoginRequest() {
        mMainModel.sendLoginRequest();
    }

    @Override
    public void onLoginSuccess() {
        mMainView.setMyAttentionLayoutVisibility(true);
        mMainView.setMyCommentLayoutVisibility(true);
    }

    @Override
    public void onLoginFail() {
        mMainView.setMyAttentionLayoutVisibility(false);
        mMainView.setMyCommentLayoutVisibility(false);
        mMainView.setLoginText("登录失败");
    }

    @Override
    public Context getContext() {
        return mMainView.getContext();
    }
}

可以看到我们这时的MainPresenter变的比较简单一点了,但是你会发现还是有一些if-else被留在了当前类,没有提到Model里:在这里插入图片描述

那这部分可不可以抽取到Model层呢,当然可以,我们只要在IMainPresenter接口中增加两个方法:
在这里插入图片描述
然后将if-else逻辑移到Model层:在这里插入图片描述
最后MainPresenter中会变成下面这样:
在这里插入图片描述
看上去更简洁了,MainPresenter就像一个只有getter和setter的普通Java类,但是这样的话几乎所有的事都扛在了Model的肩上,Model层的责任显得过重。实际上这样做只是增加了接口方法的数量而已,对业务上的管理而言并没有带来太大的方便,我个人还是倾向于将这部分留在Presenter当中,对UI的逻辑控制还是由Presenter掌握比较好,如果抽取的话,则是由Model来掌控这个逻辑权了。

下面对提取各层代码的基本判断进行一个简单的总结:

  • View层纯UI业务的操作,包括任何UI页面的加载显示(如布局、弹窗),UI属性的变化(状态、颜色、大小、位置等),UI内容的变化(填充控件数据、获取控件数据),由一个UI控件导致的另一个UI控件的状态变化,任何不涉及业务数据导致的UI变化(业务数据可能是来自网络、本地数据、页面传值等)。 这部分代码的特点是接收UI输入的变化,输出的仍然是UI相关的变化。
  • Presenter层纯逻辑业务的操作,包括在UI页面中由于任何的业务数据的获取、解析、转换等导致的UI状态变化的逻辑(业务数据可能是来自网络、本地数据库、文件、SharePref、页面传值等),在UI界面中的不限于if-else等条件判断语句导致的UI状态变化的逻辑(除UI控件本身的状态外,业务数据导致的),UI界面中其他任何不掺杂View层代码的纯逻辑代码(如一段纯算法逻辑的代码)。这部分代码的特点是接收UI输入的变化会导致业务数据的变化,而接收业务数据的输入也会导致UI的变化。
  • Model层纯数据业务的操作,包括发起网络请求、对请求结果的解析、json转换实体类、数据库查询操作、文件读写操作、SharePrfer的读写操作、数据类的拆分合并转换业务等,其他可以替代Presenter导致UI变化的数据逻辑等。这部分代码的特点是接收UI的输入会导致数据输出,而数据的输出又会反应到UI上面。

总结的还是有点啰嗦,直接来看图吧,如果你现在项目的代码是没有采用MVP而是混合在一起的代码,我想你的Acticity中的状况可能大概如下:
在这里插入图片描述
我们可以看到代码的关系几乎是错综复杂的,这也是很多工程代码的真实写照,即便会有一些封装但是他们相互之间还互相牵扯的,那么如果把它变成MVP会变成什么样呢:

在这里插入图片描述
我们看到UI控件中的关系变的简单了,整体的线的数量也少了,其实不是少了,而是被隐藏在了Model和Presenter层的内部,这个图和上面的那个图的区别是,同样在Activity的代码当中,此时的业务判断和数据存取部分对UI控件本身是完全透明的,UI并不知道另外两层在干嘛,也不需要知道,即UI对他们内部实现是完全解耦隔离的。

实际上只是将原来的混合的逻辑移到了每一层的内部:
在这里插入图片描述
这篇到这里结束,下一篇将总结:MVP模式中View层与Presenter层如何进行交互通信,Presenter层与Model层如何进行交互通信

发布了96 篇原创文章 · 获赞 57 · 访问量 11万+

猜你喜欢

转载自blog.csdn.net/lyabc123456/article/details/94379867