GMTC全球大前端技术大会(北京站)门票9折特惠截至本周五,点击立减¥480 了解详情
写点什么

安卓 MVVM 之禅

2017 年 8 月 06 日

我之前在多个 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="<a href="http://schemas.android.com/apk/res/android%22">http://schemas.android.com/apk/res/android"</a>>
<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="<a href="http://schemas.android.com/apk/res/android%22">http://schemas.android.com/apk/res/android"</a>         xmlns:app="<a href="http://schemas.android.com/apk/res-auto%22">http://schemas.android.com/apk/res-auto"</a>>   <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<mvvmcomponent> myComponent
= new ObservableField<>();
@Override
public void onAttach() {
myComponent.set(new HelloWorldComponent("World"));
}
}</mvvmcomponent>

组件类本身通过对父 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 )关注我们。

2017 年 8 月 06 日 19:003766

评论

发布
暂无评论
发现更多内容

100万级车辆数据监控的hadoop大数据架构探索与实践

黑马腾云

大数据 flink hadoop 分布式 车联网

自己挖的坑,自己填|靠谱点评

无量靠谱

Linux rm 命令

一个大红包

4月日更

从运营、产品和技术,多角度思考电商的营销体系建设

邴越

电商营销 优惠券

广告投放预算低?千人成本低才是真的省!

󠀛Ferry

四月日更

这些相见恨晚的命令行工具,你用过几个?

王坤祥

bash Linux Tool

一篇文章带你彻底了解MySQL各种约束

若尘

MySQL 数据库 约束 四月日更

2021 年带你漫游语音识别技术

清秋

人工智能 语音识别 智能音箱 签约计划 4月日更

线程池的引入和实践案例分享

小诚信驿站

线程池 线程池工作原理

机器学习水水笔记之——世界是积木吗?

Nydia

签约计划

从零开始带你打开批处理大门

xiezhr

doc 批处理 cmd

移动端混合开发选型方案分析

花花

移动端 移动开发· 签约计划

一文带你了解如何排查内存泄漏导致的页面卡顿现象

零一

chrome 前端 浏览器 内存泄露 问题处理

Java检查异常、非检查异常、运行时异常、非运行时异常的区别

Sakura

四月日更

干货版“测试小品”欢乐场景

清菡

自动化测试

ffmpeg完美实现解封装操作!

txp

音视频

Prometheus官方文档Querying[三]function

卓丁

「免费开源」基于Vue和Quasar的前端SPA项目crudapi后台管理系统实战之动态表关系管理(六)

crudapi

Vue crud crudapi quasar 表关系

uni-app跨端开发H5、小程序、IOS、Android(八):理解uni-app生命周期

黑马腾云

小程序 uni-app ios android H5

区块链国富论——财富不是物,而是全球信用共识

CECBC区块链专委会

黄金交易

和面试官简单聊聊 Elasticsearch

escray

elasticsearch elastic 4月日更 技术编辑能力考核

开源| DewCloud——通用物联网平台

云原生

物联网平台 物联网 开源项目 工业互联网 DewCloud

ElasticSearch 如何使用 ik 进行中文分词?

程序员历小冰

中文分词 elasticsearch ik 全文搜索

美团面试题:String s = new String("111") 会创建几个对象?

Java小咖秀

Java string 面试题 java面试 java对象

手把手教你基于Prometheus搭建监控告警系统

Java全栈封神

云原生 Prometheus 监控告警

自古彭城列九州 龙争虎斗几千秋|靠谱点评

无量靠谱

聪明人的训练(十一)

Changing Lin

4月日更

如何从零搭建技术团队

石云升

团队建设 28天写作 职场经验 管理经验 4月日更

【音视频】手把手带你实现超实用实时音视频工具

轻口味

android 音视频 WebRTC 移动端 OpenGL ES

我一怒之下写了个抄袭举报工具!只因一觉醒来我的文章被多个平台抄袭!

1_bit

Python selenium 签约计划 文本分析 文章查重

声网 Agora 初体验

若尘

声网 Agora

安卓 MVVM 之禅-InfoQ