BT

您是否属于早期采用者或者创新人士?InfoQ正在努力为您设计更多新功能。了解更多

安卓 MVVM 之禅

| 作者 Cain Wong  他的粉丝 ,译者 李瑞丰 关注 0 他的粉丝 发布于 2017年8月7日. 估计阅读时间: 2 分钟 | 智能化运维、Serverless、DevOps......2017年有哪些最新运维技术趋势?CNUTCon即将为你揭秘!

我之前在多个 Android 应用中采用过多种途径来实现 MVP 设计模式,并且过程中经历了反复迭代。在历经多个项目后,我决定尝试以 Android Data Binding 类库为基础来实现 MVVM。这次尝试仿佛让我陷入了Android 编程的极乐世界一般。

在带你尝试这些让我涅槃的步骤之前,我想先与你分享我在之前给自己设定的一些目标:

  • 一个 MVVM 单元应当仅由 ViewModel(VM)、ViewModel 的状态(M)以及一个绑定的布局资源文件(V)构成。

  • MVVM 单元应当是模块化的,并且支持嵌套。每个 MVVM 单元应支持包含一个或多个子单元,其中每个子单元仍可能包含自己的子单元。

  • 不需要扩展 Activity类、Fragment类,或者自定义视图。

  • 每个 ViewModel 的行为应当是可接受和可预期的,并且不依赖任何特殊的 Android 类库。应该可以使用 Vanilla JUnit 对其进行单元测试。

  • ViewModel 间的关系应当通过依赖注入来实现。

  • 应在布局文件中声明对 ViewModel 属性或者方法单向和双向的数据绑定。

  • ViewModel 不应了解其所支持的 View 的细节。ViewModel 中不应当包含来自 theandroid.view 或者 android.widgetpackages 的任何引用。

  • ViewModel 应当自动绑定到与其配对的 View 的生命周期,并在生命周期结束后自动解除绑定。

  • ViewModel 应当独立于 Activity 的生命周期,但是当 Activity 需要的时候也可以访问到 ViewModel。

  • 这个模式需要支持单个或者多个 Activity 的情况。

写在前面的话

在开始的时候,我选择了一些不出名(但是同样好用的)工具:用于管理依赖注入的 Toothpick,以及用于导航和管理栈回退(back-stack)的 Okuki(我自己写的)。我猜别人可能喜欢使用 Dagger 来管理依赖注入(DI),也可能喜欢使用 Intents、EnentBus 来完成导航功能,甚至于使用自定义的导航管理机制。你也可能倾向于使用 Activity 和 Fragments 来进行栈回退的管理。* 以上完全取决于个人。我仅推荐你遵循中心化和松耦合的原则来实现上述功能。只要保证这两个原则不变,采用了什么设计模式,如 MVP、MVVM,还是其他 UI 框架都不重要。

  • 在文章最后包含了一种建议的栈回退的管理方式:FragmentManager。

基础 ViewModel 及其生命周期

接下来的步骤里,为了实现依赖注入、导航和栈回退,我定义了一个 ViewModel 基础接口,并规定了附加、分离相关 View 生命周期的方法。

首先我定义了一个 ViewModel 接口:

public interface ViewModel {
    void onAttach();
    void onDetach();
}

下一步,我使用了 data binding 库中的 View.OnAttachStateListener 来实现绑定,然后将 android:onViewAttachedToWindowandroid:onViewDetachedFromWindow 映射到我的 ViewModel 类的对应方法当中。我实现了这些方法,并将其关联到 ViewModel 接口的 onAttachonDetach 方法上。通过这种方式,我可以在相应的扩展类当中隐藏所必需的 View 参数。此外,我还在 View 的生命周期中集成了依赖注入和 Rx 自动订阅机制。

我实现的 ViewModel 基础类:

public abstract class BaseViewModel implements ViewModel {
    private final CompositeDisposable compositeDisposable = new CompositeDisposable();
    @Override
    public void onAttach() {
    }
    @Override
    public void onDetach() {
    }
    public final void onViewAttachedToWindow(View view) {
        onAttach();
    }
    public final void onViewDetachedFromWindow(View view) {
        compositeDisposable.clear();
        onDetach();
    }
    protected void addToAutoDispose(Disposable... disposables) {
        compositeDisposable.addAll(disposables);
    }
}

现在,就可以直接使用该基类的任意 ViewModel 扩展了。你只需要将相应的 ViewModel 绑定到这个布局当中,同时把附加、分离属性映射到根 ViewGroup 即可。就像下面这样:

<layout xmlns:android="http://schemas.android.com/apk/res/android">
>  <data>
    <variable name="vm" type="MyViewModel"/>
  </data>
<FrameLayout
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:onViewAttachedToWindow="@{vm::onViewAttachedToWindow}"
  android:onViewDetachedFromWindow="@{vm::onViewDetachedFromWindow}"
>
</FrameLayout>
</layout>

模块化单元

到现在,我已经能够实现将 ViewModel 绑定到一个视图以及视图的生命周期。下一步我需要一种一致的、模块化的方式将 MVVM 单元加载到容器当中。首先我定义了一个接口,在这个接口中规定了 ViewModel 和布局资源的关联关系。

public interface MvvmComponent {
    int getLayoutResId();
    ViewModel getViewModel();
}

接下来,我在 MvvmComponent 中定义了一个自定义的数据绑定关系。这个绑定帮助完成布局的渲染、ViewModel 的绑定,并加载到一个 ViewGroup 当中。

@BindingAdapter("component")
public static void loadComponent(ViewGroup viewGroup, MvvmComponent component) {
  ViewDataBinding binding = DataBindingUtil.inflate(LayoutInflater.from(viewGroup.getContext()), component.getLayoutResId(), viewGroup, false);
  View view = binding.getRoot();
  binding.setVariable(BR.vm, component.getViewModel());
  binding.executePendingBindings();
  viewGroup.removeAllViews();
  viewGroup.addView(view);
}

需要注意的是,我在渲染的过程中将 attachToParent 参数设置为 false,然后在绑定完成后通过显式地执行 addView(view) 方法来完成附加。我这样做的原因是为了 ViewModel 的 onViewAttachedToWindow 方法能够正常被调用,因为这个方法需要 View 在渲染之前就绑定 ViewModel。

现在我可以使用新的绑定关系了。在我的布局文件中,我通过新增 component 属性的方式来添加一个 ViewGroup 容器。

<layout xmlns:android="http://schemas.android.com/apk/res/android"         xmlns:app="http://schemas.android.com/apk/res-auto">   <data>     <variable       name="vm"       type="MyViewModel"/>   </data>   <FrameLayout     android:id="@+id/main_container"     android:layout_width="match_parent"     android:layout_height="match_parent"     android:onViewAttachedToWindow="@{vm::onViewAttachedToWindow}"     android:onViewDetachedFromWindow="@{vm::onViewDetachedFromWindow}"     app:component="@{vm.myComponent}"   />
</layout>

我通过使用 ObservableField<MvvmComponent> 来在我的 ViewModel 中提供断开组件的方式。

public class MyViewModel extends BaseViewModel {
  public final ObservableField myComponent 
     = new ObservableField<>();
  @Override
  public void onAttach() {
    myComponent.set(new HelloWorldComponent("World"));
  }
}

组件类本身通过对父 ViewModel 的调用,提取出了资源 ID 和子 ViewModel 的定义,并且在父 ViewModel 传递过来的数据中,只接受那些子 ViewModel 初始化过程需要的参数。

public class HelloWorldComponent implements MvvmComponent {
private final String name;
  public HelloWorldComponent(String name){
    this.name = name;
  }
  @Override
  public int getLayoutResId() {
    return R.layout.hello_world;
  }
  @Override
  public ViewModel getViewModel() {
    return new HelloWorldViewModel(name);
  }
}

到现在,子组件可以轻松在 ViewModel 状态的基础上加载。而这个过程并不需要 ViewModel 对布局、View 或者其他 ViewModel 有任何的了解。

Activity 生命周期

按照开始的计划,我的 MVVM 单元独立于 Activity 生命周期之外。但有时候我们又需要访问它。我们可以通过在 Bundle 实例中保存、恢复的方式来实现,也可以通过实现对暂停、恢复事件的响应的办法来完成。这些都可以根据实际需求来选择,并且比较简单。只需要把这些事件委托给一个继承了 Application.ActivityLifecycleCallbacks 的单例类,就能实现。当然这个单例类需要注册到当前应用之上。这样这个单例类就能通过 Listeners 或者 Observables 来暴露出这些事件,并把他们注入到任何需要响应这些事件的 ViewModel当中。

使用 Fragments 完成栈回退

我在本帖一开始就提到过,我的栈回退是通过自定义的库来实现的。但是仅需要一些简单的改动,你就能将其替换为 Android 自带的 FragmentManager。为了实现这个目标,需要向 MvvmComponent 接口中增加额外的方法:

public interface MvvmComponent {
    int getLayoutResId();
    ViewModel getViewModel();
    String getTag();
    boolean addToBackStack();
}

下一步,创建一个 Fragment 来对你的 MVVM 单元进行包装,像下面这样:

public class MvvmFragment extends Fragment {
  private int layoutResId;
  private ViewModel vm;
public MvvmFragment newInstance(int layoutResId, ViewModel vm){
    MvvmFragment fragment = new MvvmFragment();
    fragment.layoutResId = layoutResId;
    fragment.vm = vm;
    return fragment;
  }
@Override
  public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
    ViewDataBinding binding = DataBindingUtil.inflate(inflater, layoutResId, container, false);
    binding.setVariable(BR.vm, vm);
    binding.setVariable(BR.fm, getChildFragmentManager());
    return binding.getRoot();
  }
  public void setLayoutResId(int layoutResId){
    this.layoutResId = layoutResId;
  }
  public void setViewModel(ViewModel vm){
    this.vm = vm;
  }
}

注意布局文件中需要声明 fm 数据变量,并且将其设置为 ViewGroup 容器的属性。同时,需要关注的还有:配置变化时造成的关联影响、layoutResId 进程僵死,以及你的 MvvmFragment 的 vm 成员属性。适当的调整你的 Fragment 参数也很有必要。

现在你可以通过修改自定义组件的方式来使用你的 MvvmFragment,而不是直接渲染并绑定 ViewModel。

@BindingAdapter({"component", "fm"})
public static void loadComponent(ViewGroup viewGroup, MvvmComponent component, FragmentManager fm) {
  MvvmFragment fragment = fm.findFragmentByTag(component.getTag()); 
  if(fragment == null) { 
    fragment = MvvmFragment.newInstance(component.getLayoutResId, component.getViewModel());
  }
  FragmentTransaction ft = beginTransaction();
  ft.replace(viewGroup.getId, fragment, component.getTag());
  if(component.addToBackStack()){
    ft.addToBackStack(component.getTag());
  }
  ft.commit();
}

示例应用

如果你想参考一个完整的、使用 MVVM 来实现的(没有 Fragments)应用示例,可以在 这里 参考我的例子。

编程愉快!

查看英文原文:Zen Android MVVM


感谢冬雨对本文的审校。

给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ@丁晓昀),微信(微信号:InfoQChina)关注我们。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我
社区评论

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT