通过例子手撕架构模式

1,602 阅读16分钟

更多精品文章分类

关于架构

关于架构的概念很宽泛,不是一句 MVP、MVC、MVVM 就能说清楚的。

一般开发软件的时候,我们是如何进行架构设计的呢?

首先一个 APP 软件是一个大的系统,我们通常可以把这个大的系统划分为许多个小的模块,比如:登录注册功能,首页展示功能、个人信息功能等等某个具体的模块的功能。然后我们就可把这几个相对独立的模块分别划分给不同的人员进行开发。

当然在进行模块划分前,我们需要先把整个软件的架子搭建起来,比如:请求网络需要用什么架构、加载图片需要使用什么架构、传递数据需要什么架构等等,这里的架构用作第三方库替代一下可能更加贴切一下。但是这的确是我们整个 App 架构的一部分,可以说是最底层的一部分,我把这部分称为底层技术架构。

然后在这一层的基础上,衍生出相对公共的业务层。比如登录、注册、一些通用的业务,这些模块相对来说比较独立,变化性不大,在这一层上面就是核心的业务层了,变动比较大。

注意这些层次并不是物理上的层次。

至于 MVC、MVP、MVVM 也常被称为软件架构,维基百科的定义就是:是软件工程中的一种软件架构模式。把软件系统分为了不同的部分,比如 MVC 把软件系统分为了三个基本部分:模型(Model)、视图(View)和控制器(Controller)。

其实提到 MVC、MVP、MVVM 我个人更倾向于,这是针对软件中的某个功能或者业务使用这种书写方式,每个模块都是用了这种模式,那么整体的软件说起来就可以说是这个 APP 是用了 MVC 模式。其实说白了 MVC、MVP、MVVM 等等都是一种写代码的设计规范(可能这个词不太准确)没有具体的概念,为了以后更加方便的迭代代码,使代码看起来更加的清晰。更偏向于一种书写代码的模型,一种代码理念,而不是新的技术。这种理念就是为了让代码更清晰,耦合性降低,还要根据具体的业务来使用,不能照搬硬套。

下面再来具体说一下 MVC、MVP、MVVM 只是介绍一下用这三种模式来写代码,代码的结构应该是什么样的,只是通用,并不是说只能这样,具体根据业务来 下面所说的软件架构模式都是指的某种编写代码的理念,比如:MVP、MVC 甚至是你自己的理念。

为什么会出现架构模式

架构模式的出现是为了让代码更加的清晰,相互之间耦合性低,非常庞大的项目,便于以后的迭代升级,让程序划分更加清晰(视图显示、业务逻辑/数据处理都独立开)这就是进行架构模式的意义,你想如果你的程序非常庞大结果你就全部都写在了一个java 类中,一个 java 类中几万行代码,是不是想想都恐怖,当然任何普通人都不会这么写吧,都会有自己的架构模式,比如有的人把这个 java 类拆分了 4 个类,有的拆了 10 个类。等等这其中就有一种是比较适合的,随着不断的发展,就有人提出了 MVC 这种架构模式,使用这种架构模式,可以让 java 类中的不同内容分离,比其他人的方式更加合理,于是就有了 MVC 架构模式

上面的三种架构模式,都有自己的使用模型,使用不同的模式,代码层次划分的也不一样。

架构模式的目的

  • 模块化功能

    不同的功能使用不同的模块,不要全部大杂烩。合理的架构模式会让代码各部分相互独立,耦合性降低。

  • 提高开发效率

    开发人员只需要专于某一块内容(视图显示、业务逻辑)

  • 便于日后维护

    便于扩展维护

不能为了设计而设计,要根据项目实际情况来,如果项目很小,弄上复杂的架构模式,效果反而不好。

对于量级比较小的项目,只需要划分一下层次,规范一下,提炼一下方法就可以了。

对于量级较大的项目,才需要引入较为复杂的框架。

下面分别来讲 MVC MVP MVVM 在 Android 开发中的运用,只是针对 Android 项目开发。

通过项目分析

这里有个登录功能,功能很简单。如图:

就是一个模拟登录的功能,输入用户名,密码后点击登录进行页面登录,登录结果会给页面一个反馈。

没有架构

我们先来看看没有架构的时候,是怎么进行开发的

为了便于说明我在代码中有标识数字,便有后面解释。

页面内容很简单,这里就不再粘贴 xml 页面部分了,只粘贴 java 代码部分。

public class LoginActivity extends BaseActivity {

    @BindView(R.id.tv_username)
    TextView tvUsername;
    @BindView(R.id.et_username)
    EditText etUsername;
    @BindView(R.id.et_password)
    EditText etPassword;
    @BindView(R.id.bt_login)
    Button btLogin;
    @BindView(R.id.bt_clear)
    Button btClear;
    @BindView(R.id.pb)
    ProgressBar pb;
    @BindView(R.id.tv_status)
    TextView tvStatus;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_mvp_login);
        ButterKnife.bind(this);
    }

    @Override
    public void initView() {
        super.initView();
        btClear.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // ① 
                etPassword.setText("");
                etUsername.setText("");

            }
        });
        // 这里接受用户点击事件传递,再进行登录前需要进行页面逻辑判断
        btLogin.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                if (etUsername.getText() == null || etUsername.getText().toString().trim().equals("")) {
                    Toast.makeText(LoginActivity.this, "用户名不能为 null", Toast.LENGTH_SHORT).show();
                    return;
                }
                if (etPassword.getText() == null || etPassword.getText().toString().trim().equals("")) {
                    Toast.makeText(LoginActivity.this, "密码不能为 null", Toast.LENGTH_SHORT).show();
                    return;
                }
                login(etUsername.getText().toString(), etPassword.getText().toString());

            }
        });
    }

    /**
     * 登录业务,牵扯到数据
     * 真实情况下,有可能是 网络请求、本地数据库操作、大量数据逻辑操作
     * @param name
     * @param password
     */
    public void login(String name, String password) {
        FormBody formBody = new FormBody.Builder()
                .add("username", name)
                .add("password", password)
                .build();
        Request request = new Request.Builder()
                .post(formBody)
                .url("http://192.168.1.120/login")
                .build();
        OkHttpUtil.enqueue(request, new Callback() {
            @Override
            public void onFailure(@NotNull Call call, @NotNull IOException e) {
                // 处理一些业务逻辑
                tvStatus.setText("登录失败");
            }

            @Override
            public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException {
                // 来更新页面内容
                tvStatus.setText("登录成功");
            }
        });
    }
}

这就是没有使用架构进行开发的代码,很明显的不足就是所有的代码都放在了 Activity 中,不管是对 view 的操作还是对数据处理还是一些页面的逻辑判断,这样的代码写的多了,可读性和可扩展性都会变的非常差。

不得不说,我上面写的代码这样看上去还是不够乱,其实一方面是我们的功能不够复杂,功能很简单,你才能够一眼就看清楚,试想里面如果有几十个几百个逻辑呢?(你可能会说了,我可以把一些内容抽离出去,其实这就是一种简单的架构思想,只不过可能不太符合 mvc 架构的核心思想,但是只要是适合你的项目就是最好的架构),另一方面是,其实是 Android 开发本身是遵循一定的 MVC 思想的。View 放在 xml 中与 Java 代码解耦,然后在 Activity 中充当 Controller 处理逻辑控制,但是这样有一个问题就是没有对 Model 进行划分,而且 xml 功能太简单只能作为一个静态的页面,难免会在 Activity 中操作页面。这样也就导致了 Activity 里面的功能过多,既要充当 View 的功能又要充当 controller 的功能。

MVC

一般MVC

首先一般在 Android 中提到 MVC 的时候,都是这样来分类的

  • View:对应布局文件
  • Model:牵扯到数据的业务逻辑和实体模型
  • Controller:对应 Activity,进行操控 Model 和 页面,还有部分页面逻辑判断

但是细看你就会发现,View 对应布局文件,布局文件就是一个 xml 可以做的事情实在是太少了,一般就是写完页面就完事了,真正动态修改页面的时候还是需要通过在 Activity 中获取对应的控件来进行修改。很显然这样的结果就是 Activity 既要做 View 的部分工作又要做 Controller 的部分工作,其实这种模式就是 MV 没有了 C,是一种不标准的 MVC 模式。

按照这个思路写代码

// 定义了一个 View 的接口,把 View 要做的功能列出来
// 因为 Model 需要通知 View 刷新页面,通过接口的方式可以减少 Model 对某个页面的依赖
public interface IView {
     void setLoginStatus(String loginStatus);

     void loginFail(String fail);
}

// 这就是我们的 View 和 Controller 的集合体
public class LoginMVCActivity extends BaseActivity implements IView {
    @BindView(R.id.tv_username)
    TextView tvUsername;
    @BindView(R.id.et_username)
    EditText etUsername;
    @BindView(R.id.et_password)
    EditText etPassword;
    @BindView(R.id.bt_login)
    Button btLogin;
    @BindView(R.id.bt_clear)
    Button btClear;
    @BindView(R.id.pb)
    ProgressBar pb;
    @BindView(R.id.tv_status)
    TextView tvStatus;
    LoginModel loginModel;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_mvp_login);
        ButterKnife.bind(this);
    }

    @Override
    public void initView() {
        super.initView();
        loginModel = new LoginModel(this);
        btClear.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                etPassword.setText("");
                etUsername.setText("");

            }
        });
        btLogin.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // 这样的判断逻辑是属于 Controller 的职责的
                if (etUsername.getText() == null || etUsername.getText().toString().trim().equals("")) {
                    Toast.makeText(LoginMVCActivity.this, "用户名不能为 null", Toast.LENGTH_SHORT).show();
                    return;
                }
                // 这样的判断逻辑是属于 Controller 的职责的
                if (etPassword.getText() == null || etPassword.getText().toString().trim().equals("")) {
                    Toast.makeText(LoginMVCActivity.this, "密码不能为 null", Toast.LENGTH_SHORT).show();
                    return;
                }
                // 调用 Model 中的登录业务逻辑
                loginModel.login(etUsername.getText().toString(), etPassword.getText().toString());

            }
        });
    }

	
    @Override
    public void setLoginStatus(String loginStatus) {
        tvUsername.setText(loginStatus);
    }

    @Override
    public void loginFail(String fail) {
        tvUsername.setText(fail);
    }

}


// 有了 IView ,这样 LoginModel 就不会依赖某个具体的 activity 了,想要复用也可以了,
// 不然 LoginModel 就是专门为某个 Activity 而准备的
public class LoginModel {
    private IView iView;
    public LoginModel(IView iView){
        this.iView = iView;
    }


    public void login(String name,String password){
        FormBody formBody = new FormBody.Builder()
                .add("username",name)
                .add("password",password)
                .build();
        Request request = new  Request.Builder()
                .post(formBody)
                .url("http://192.168.1.120/login")
                .build();
        OkHttpUtil.enqueue(request, new Callback() {
            @Override
            public void onFailure(@NotNull Call call, @NotNull IOException e) {
                // 登录失败了,真实情况下可能会经过一些逻辑判断,然后通知页面发生更新
                //..... 等等一些逻辑处理
                iView.loginFail("");
            }

            @Override
            public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException {
                //..... 等等一些逻辑处理
                // 来更新页面内容
                iView.setLoginStatus("登录成功");
            }
        });
    }

}

这种写法是把 Controller 和 View 混在一起了,让 M 分离了,实际这就模式是 MV,Activity 责任不明确、十分臃肿,除了担任了 View 层的部分职责(加载应用布局、接受用户操作、更新布局内容)还有担任 Controller 层的职责(部分关于页面的逻辑),这样随着界面内容增多,逻辑变得根据复杂的话,Activity 中的代码会不断增加。下面来进一步升级一下这个不太标准的 MVC。

升级版MVC

// 首先把 Activity 中应该 Controller 来做的内容抽离出来
// 使用接口的好处就是依赖没有那么强,可以有不同的实现,相互不影响,再一个就是便于阅读,有的时候我们不需要关心具体是怎么实现的,只要知道有哪些方法,这些方法可以做什么就可以了,这样在接口里面一眼就可以看到了,如果没有接口的话就要看方法,方法就意味这有方法体,如果实现过程很复杂,一个类中有好几十个这种方法,那么看起来很费劲。
public interface ILoginController {

    void login(String name,String password);
}


public class LoginController implements ILoginController{
    private IView iView;
    private ILoginModel iLoginModel;

    public LoginController(IView iView,ILoginModel iLoginModel){
        this.iView = iView;
        this.iLoginModel = iLoginModel;
    }

    @Override
    public void login(String name, String password) {
        if (name== null || name.trim().equals("")) {
            iView.loginFail("用户名不能为 null");
            return;
        }
        if (password == null || password.trim().equals("")) {
            iView.loginFail("密码不能为 null");
            return;
        }
        iLoginModel.login(name, password);
    }
}

public interface IView {
     void setLoginStatus(String loginStatus);

     void loginFail(String fail);

     void error(String error);
}

public class LoginMVCActivity extends BaseActivity implements IView {
    @BindView(R.id.tv_username)
    TextView tvUsername;
    @BindView(R.id.et_username)
    EditText etUsername;
    @BindView(R.id.et_password)
    EditText etPassword;
    @BindView(R.id.bt_login)
    Button btLogin;
    @BindView(R.id.bt_clear)
    Button btClear;
    @BindView(R.id.pb)
    ProgressBar pb;
    @BindView(R.id.tv_status)
    TextView tvStatus;
    LoginModel loginModel;
    ILoginController loginController;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_mvp_login);
        ButterKnife.bind(this);
    }

    @Override
    public void initView() {
        super.initView();
        loginModel = new LoginModel(this);
        loginController = new LoginController(this, loginModel);
        btClear.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                etPassword.setText("");
                etUsername.setText("");

            }
        });
        btLogin.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                loginController.login(etUsername.getText().toString(),
                        etPassword.getText().toString());

            }
        });
    }

    @Override
    public void setLoginStatus(String loginStatus) {
        tvUsername.setText(loginStatus);
    }

    @Override
    public void loginFail(String fail) {
        tvUsername.setText(fail);
    }

    @Override
    public void error(String error) {
        Toast.makeText(this, error, Toast.LENGTH_SHORT).show();
    }

}


public interface ILoginModel {
    void login(String name,String password);
}

public class LoginModel implements ILoginModel{
    // 通常情况下 Model不会直接持有 View,而是通过接口回调(具体的接口实现在 Controller 中实现)来更新View
    private IView iView;
    public LoginModel(IView iView){
        this.iView = iView;
    }


    public void login(String name,String password){
        FormBody formBody = new FormBody.Builder()
                .add("username",name)
                .add("password",password)
                .build();
        Request request = new  Request.Builder()
                .post(formBody)
                .url("http://192.168.1.120/login")
                .build();
        OkHttpUtil.enqueue(request, new Callback() {
            @Override
            public void onFailure(@NotNull Call call, @NotNull IOException e) {
                // 登录失败了,真实情况下可能会经过一些逻辑判断,然后通知页面发生更新
                //..... 等等一些逻辑处理
                iView.loginFail("");
            }

            @Override
            public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException {
                //..... 等等一些逻辑处理
                // 来更新页面内容
                iView.setLoginStatus("登录成功");
            }
        });
    }

}

好了,到此这个升级版本的 MVC 就完成了,这样结构看上去就很清晰了,各层次之间互相分离。

现在的 MVC,M就是一个单独的类,V 就是 Activity 和 xml,C 也用了一个单独类

虽然我们已经做了升级比之前的 MV 版本好了很多,但是你会发现 MVC 之间相互依赖的关系太多,V 可以和 C 直接进行通信,而 M 和 V 之间也可以直接进行通信,这就导致了 M 需要依赖 V,彼此之间依赖关系太多。于是就出现了 MVP,MVP的出现就是为了彻底断绝 M 和 V 之间的联系。

总结:MVC

M:包括用于操作与数据有关的复杂逻辑和实体模型

V:专门用于页面的展示

C:一些简单的逻辑(不牵扯到数据),串联 M 和 V

MVC最致命的问题就是 M 可以和 V进行通信

来自网络

通过这张图就可以看出,View Controller Model 之间相互依赖关系过多。

MVP

为了解决升级版 MVC存在的一些问题,出现了 MVP

  • M Model 数据层,用于操作与数据有关的复杂的业务逻辑和定义实体模型
  • V 视图层,View 的绘制刷新,用户交互 对应 Android 中的 Activity 和 xml
  • P Presenter 用于连接 V 层和 M层,处理一些简单的业务逻辑

在 MVP 中 M和V之间是不能通信的,必须要借助 P

代码实现:

// M 部分
public interface ILoginModel {
    void login(String name,String password);
}

public class LoginModel implements ILoginModel {
	// 这里也可以不持有 LoginPresenter 通过持有一个回调来完成
    ILoginPresenter iLoginPresenter;
    public LoginModel(ILoginPresenter iLoginPresenter){
        this.iLoginPresenter = iLoginPresenter;
    }
    public void login(String name,String password){
        FormBody formBody = new FormBody.Builder()
                .add("username",name)
                .add("password",password)
                .build();
        Request request = new  Request.Builder()
                .post(formBody)
                .url("http://192.168.1.120/login")
                .build();
        OkHttpUtil.enqueue(request, new Callback() {
            @Override
            public void onFailure(@NotNull Call call, @NotNull IOException e) {
                // 登录失败了,真实情况下可能会经过一些逻辑判断,然后通知 Presenter
                //..... 等等一些逻辑处理
                iLoginPresenter.result("");
            }

            @Override
            public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException {
                //..... 等等一些逻辑处理
                // 通知 Presenter
                iLoginPresenter.result("");
            }
        });
    }
}

// V 部分

public interface IView {
     void setLoginStatus(String loginStatus);

     void loginFail(String fail);

     void error(String error);
}

public class LoginMVPActivity extends BaseActivity implements IView {


    @BindView(R.id.tv_username)
    TextView tvUsername;
    @BindView(R.id.et_username)
    EditText etUsername;
    @BindView(R.id.et_password)
    EditText etPassword;
    @BindView(R.id.bt_login)
    Button btLogin;
    @BindView(R.id.bt_clear)
    Button btClear;
    @BindView(R.id.pb)
    ProgressBar pb;
    @BindView(R.id.tv_status)
    TextView tvStatus;
    // Presenter
    ILoginPresenter loginPresenter;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_mvp_login);
        ButterKnife.bind(this);
    }

    @Override
    public void initView() {
        super.initView();
        loginPresenter = new LoginPresenter(this);
        btClear.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                etPassword.setText("");
                etUsername.setText("");

            }
        });
        btLogin.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                loginPresenter.login(etUsername.getText().toString(),
                        etPassword.getText().toString());

            }
        });
    }

    @Override
    public void setLoginStatus(String loginStatus) {
        tvUsername.setText(loginStatus);
    }

    @Override
    public void loginFail(String fail) {
        tvUsername.setText(fail);
    }

    @Override
    public void error(String error) {
        Toast.makeText(this, error, Toast.LENGTH_SHORT).show();
    }
}

// Presenter

public interface ILoginPresenter {
    void login(String name,String password);
    void result(String name);
}


public class LoginPresenter implements ILoginPresenter {

    IView iView;
    ILoginModel iLoginModel;

    public LoginPresenter(IView iView){
        this.iView = iView;
        // 不想让 Model持有 Presenter 的时候,同样这里可以传入一个 CallBack的实现
        // 当 Model 完成后回调一下就可以了
        iLoginModel = new LoginModel(this);
    }

    @Override
    public void login(String name, String password) {
        if (name== null || name.trim().equals("")) {
            iView.loginFail("用户名不能为 null");
            return;
        }
        if (password == null || password.trim().equals("")) {
            iView.loginFail("密码不能为 null");
            return;
        }
        iLoginModel.login(name, password);
    }

    @Override
    public void result(String name) {
        // 这里就是可以进行一些逻辑判断,然后根据情况通知页面刷新
        if(name.equals("==")){
            iView.loginFail(name);
        }else {
            iView.setLoginStatus(name);
        }
    }
}

特点总结

这样一个最简单的 MVP 模式的代码就完成了,对比升级版 MVC最大的特点就是阻断了 M 和 V 之间的关系。P 持有 Model 和 View,Model 和View 的一切通信都是通过 P 来完成。

这样就演变成只有 P 依赖于 Model 和 View 了(都通过接口实现,不依赖具体的类),做到了彼此的分离。

来自网络

M:Model 与数据有关的逻辑业务

View:View 的绘制,视图展示,与用户交互 Activity

P:Presenter 串联 M 和 V,完成 M 和 V 层的交互,部分业务逻辑处理,持有 M 和 V 的对象。

MVP 的封装

MVP 写法是有一定规律的,并且层次分的很明显,这里我们封装一下 MVP 中共用的一些操作,作为三个对应的底层接口

interface BaseModel{
    
}

interface BaseView{
    void showError(String msg);
}

public abstract class BasePresenter<V extends BaseView,M extends BaseModel>{
    protected V view;
    protected M model;
    
    public BasePresenter(){
        model = createModel();
    }
    
    void attachView(V view){
        this.view = view;
    }
    // 为了防止内存泄漏 Activity 销毁的时候将 View 置空
    void detachView(){
        this.view = null;
    }
    
    abstract M createModel();
}

// 这里这三个就是各个层的基类,可以根据需求在里面写一些通用的方法
// BasePresenter 利用了泛型,Presenter 必须同时持有 View 和 Model 的引用,但是在底层接口中无法确定他们的类型,
// 只能确定他们是 BaseView 和 BaseModel 的子类,所以采用泛型的方式来引用,就解决这个问题了,在 BasePresenter 的子类中只要定义好 View 和 Model 的类型,就会自动应用他们的对象了。

然后为了方便查看和修改把同一个页面的内容全部放到一个 Contract 契约接口中

// 这样查看起来就非常的清晰了
interface TestContract{
    interface Model extends BaseModel{
        void method1(Callback1 callback1);
        void method2(Callback2 callback2);
        .....根据项目所需的业务添加方法声明;
    }
    interface View extends BaseView{
        void updateUI1();
        void updateUI2();
        ....根据实际其他添加方法声明;
    }
    abstract class Presenter extends BasePresenter<View,Model>{
        abstract void request1();
        abstract void request2();
       	.....根据项目添加适当的方法声明;
    }
    
    interface Callback1{
        void handlerResult();
        ......根据实际情况添加;
    }
    
}

然后在 Activity 或者 Fragment 中去实现 TestContract.View 的接口,再分别创建两个类用来实现 Test.Contract.Presenter 和 Test.Contract.Model 方法就可以了。

总结

所有的架构目的都是为了让代码扩展性更强,彼此依赖性更低,各个层功能更独立更专一。这样就导致不断的分层,理论上说分的层次越多,各个层次的功能就越专一,付出 的代价就是代码结构会变得复杂。所以这个分几层要根据项目来决定!

M 是负责业务逻辑和数据模型的组合,其实要说的话,M 下面应该还有层次(获取数据)(进行具体的网络查询业务等等不过一般情况就这就把这些内容写入到 M 里面了)

V 就是视图层

X 应该是用来进行表现层业务逻辑的,表现层说简单一点其实就是操控页面应该如何显示,与页面有直接关系的一些逻辑。这些逻辑不叫做业务逻辑而叫做表现层逻辑。

参考:www.jianshu.com/p/f17f5d981…

mp.weixin.qq.com/s?__biz=MzI…

blog.csdn.net/lmj62356579…

juejin.cn/post/684490…

mp.weixin.qq.com/s/_6p6vfce7…

mp.weixin.qq.com/s/OEzcsPZHC…

www.jianshu.com/p/f17f5d981…