原文链接:
ANDROID
DATABINDING: GOODBYE PRESENTER, HELLO VIEWMODEL!
MVP(Model-View-Presenter)近来成为Android应用的UI层架构设计中主要的设计模式。例如Ted Mosby,Nycleus和Mortar 等框架都引入Presenters来帮助你的应用构建一个整洁的架构(clean
architecture)。他们也在不同程度上帮助你解决Android平台上的设备旋转和状态保持等问题。这虽然和MVP不直接相关,但是使用该模式可以帮助你隔离这些模板代码。
Data Binding,在2015年谷歌IO大会上引入,是一项重要的改变。在维基百科中MVP中Presenter的作用是这样描述的:
Presenter作用于model和view之间。它从model中获取数据,并且格式化数据供view来显示(The
presenter acts upon the model and the view. It
retrieves data from repositories (the model), and formats it for display in the view.)
Data Binding框架代替了Presenter(作用于model和view之间)的主要功能,剩余的(获取数据并格式化供显示)则由增强型的Model--ViewModel来实现。ViewModel是标准的JAVA类,它唯一的责任就是为一个视图提供数据。它可以从多个数据源(Models)中合并数据,然后供界面显示。我写了一篇文章描述了ViewModel和Data
Model与Transport Model间的区别。
这个架构就是MVVM--Model-View-ViewModel,这个概念最初由微软在2005年提出来。让我们介绍下从MVP到MVVM的变动,下面的图片是拷贝Hanne Dorfmann‘s介绍Ted Mosby框架中的插图
因此,所有View对应数据的绑定和更新都是通过Data Binding框架来实现。ObservableField类支对model的改动同步到View,同样当用户操作View时,属性的变动也可以推送到ViewModel,前提是在XML中引用ViewModel中的该属性。当然你也可以通过代码监听属性的变动,这样就能实现CheckBox按下TextView就变成成灰色的功能。但是将View的视觉状态的改变用标准的JAVA来实现的一个好处是:易于单元测试。
在MVP插图中有一个方法的调用Presenter.loadUsers(),这是一个命令。在MVVM中这些方法调用被定义在ViewModel中。来自维基百科:
ViewModel 是一个抽象View,它定义了公共的属性和命令。
这可能会改变你的编码习惯,在MVP模式中,Model只包含数据不包含业务。但是不要害怕将业务逻辑定义在Model或者ViewModel中,这本来就是面向对象编程的核心思想(译者注:类中包含数据也包含操作数据的方法)。回到Presenter.loadUsers()方法--这个方法会被定义在ViewModel中,该方法可能会在Activity中调用,也可能通过XML资源文件中的数据绑定命令(data
bound command)来执行(该功能谷歌已经给出承诺,但是还没实现)。如果不能支持数据绑定命令那只有借助古老的android:onClick或者添加视图的监听器。
系统调用的处理
有些系统初始化的调用,如打开Dialog或者任何需要Context对象的调用,仍然需要定义在Activity中。不要把这种代码放在ViewModel中。如果ViewModel中包含import android.content.Context;,那么说明你的代码是错误的,千万不要这样子做(译者注:不包含android平台的代码,才方便做单元测试)。
有几种好的方法可以避免以上的问题,One way would be to keep elements of the presenter concept from Mosby by referencing an interface to the View in the ViewModel.
This way you won’t reduce the testability. But instead of having a seperate Presenter class as in Mosby, I’d stick to the View as the concrete implementation of that interface just to keep it simple. Another approach could be to use an event bus like Square’s
Otto to initiate commands like new
. This will yield a greater separation of the view and the viewmodel – but is that a good thing?
ShowToastMessage("hello world")
现在还需要框架吗?
说了那么多,Data Binding可以代替Mosby或者Mortar框架了?答案是:一定程度上可以代替。希望可以看到这些框架继续发展或者引入MVVM模式,这样我们就可以最好的利用Data Binding同时减少第三方库的依赖。虽然MVP最近势头减弱,但是视图状态变化的持久性(view state【ViewModel】 persistence和生命周期管理同样重要。 最近就看到了这样的一个框架:AndroidViewModel
framework。
概要
当我听说Android M中引入了Data Binding时,我意识到它可以帮助我们构建干净的架构。但是它也不能包治百病--也存在缺点。在XML中定义就是问题,因为XML是不会被编译的,XML也是不能进行单元测试的。因此,大部分应该在编译时发现的错误,却要等到运行时才能发现。如果有工具可以帮助我们那就不同了--所以希望谷歌可以在Android Studio中加入工具对Data Binding提供更好支持。比如语法检查,引用检查,自动补齐,属性重命名自动更新到XML等功能。
例子
下面是一个例子,放在一起是为了演示MVP和MVVM的不同。例子中使用了Mosby作为MVP框架并使用了Butterknife作为注入框架。在MVVM演示中只使用了Data Binding。Presenter被丢弃了,Fragment中的代码更少了,但是ViewModel承担了更多的责任,增加了更多的代码。
在本例中我直接引用了View为了产生Toast Message,这是不提倡的。使用Robolectric和Mockito模拟Fragment后它是可以单元测试的。
这个应用包含一个登陆界面,有些数据是异步加载的
想要在AS中阅读代码,进入此Github repo
MVP – VIEW – XML
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingRight="@dimen/activity_horizontal_margin" android:paddingTop="@dimen/activity_vertical_margin" android:paddingBottom="@dimen/activity_vertical_margin" tools:context=".MainActivityFragment"> <TextView android:text="..." android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:id="@+id/loggedInUserCount"/> <TextView android:text="# logged in users:" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="false" android:layout_toLeftOf="@+id/loggedInUserCount"/> <RadioGroup android:layout_marginTop="40dp" android:id="@+id/existingOrNewUser" android:gravity="center" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerHorizontal="true" android:orientation="horizontal"> <RadioButton android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Returning user" android:id="@+id/returningUserRb"/> <RadioButton android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="New user" android:id="@+id/newUserRb" /> </RadioGroup> <LinearLayout android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="wrap_content" android:id="@+id/username_block" android:layout_below="@+id/existingOrNewUser"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="?android:attr/textAppearanceMedium" android:text="Username:" android:id="@+id/textView" android:minWidth="100dp"/> <EditText android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/username" android:minWidth="200dp"/> </LinearLayout> <LinearLayout android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_alignParentStart="false" android:id="@+id/password_block" android:layout_below="@+id/username_block"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="?android:attr/textAppearanceMedium" android:text="Password:" android:minWidth="100dp"/> <EditText android:layout_width="wrap_content" android:layout_height="wrap_content" android:inputType="textPassword" android:ems="10" android:id="@+id/password"/> </LinearLayout> <LinearLayout android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_below="@+id/password_block" android:id="@+id/email_block"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="?android:attr/textAppearanceMedium" android:text="Email:" android:minWidth="100dp"/> <EditText android:layout_width="wrap_content" android:layout_height="wrap_content" android:inputType="textEmailAddress" android:ems="10" android:id="@+id/email"/> </LinearLayout> <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Log in" android:id="@+id/loginOrCreateButton" android:layout_below="@+id/email_block" android:layout_centerHorizontal="true"/> </RelativeLayout>
MVP – VIEW – JAVA
package com.nilzor.presenterexample; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.CompoundButton; import android.widget.RadioButton; import android.widget.TextView; import android.widget.Toast; import com.hannesdorfmann.mosby.mvp.MvpFragment; import com.hannesdorfmann.mosby.mvp.MvpView; import butterknife.InjectView; import butterknife.OnClick; public class MainActivityFragment extends MvpFragment implements MvpView { @InjectView(R.id.username) TextView mUsername; @InjectView(R.id.password) TextView mPassword; @InjectView(R.id.newUserRb) RadioButton mNewUserRb; @InjectView(R.id.returningUserRb) RadioButton mReturningUserRb; @InjectView(R.id.loginOrCreateButton) Button mLoginOrCreateButton; @InjectView(R.id.email_block) ViewGroup mEmailBlock; @InjectView(R.id.loggedInUserCount) TextView mLoggedInUserCount; public MainActivityFragment() { } @Override public MainPresenter createPresenter() { return new MainPresenter(); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_main, container, false); } @Override public void onViewCreated(View view, Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); attachEventListeners(); } private void attachEventListeners() { mNewUserRb.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { updateDependentViews(); } }); mReturningUserRb.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { updateDependentViews(); } }); } /** Prepares the initial state of the view upon startup */ public void setInitialState() { mReturningUserRb.setChecked(true); updateDependentViews(); } /** Shows/hides email field and sets correct text of login button depending on state of radio buttons */ public void updateDependentViews() { if (mReturningUserRb.isChecked()) { mEmailBlock.setVisibility(View.GONE); mLoginOrCreateButton.setText(R.string.log_in); } else { mEmailBlock.setVisibility(View.VISIBLE); mLoginOrCreateButton.setText(R.string.create_user); } } public void setNumberOfLoggedIn(int numberOfLoggedIn) { mLoggedInUserCount.setText("" + numberOfLoggedIn); } @OnClick(R.id.loginOrCreateButton) public void loginOrCreate() { if (mNewUserRb.isChecked()) { Toast.makeText(getActivity(), "Please enter a valid email address", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(getActivity(), "Invalid username or password", Toast.LENGTH_SHORT).show(); } } }
MVP – PRESENTER
package com.nilzor.presenterexample; import android.os.Handler; import android.os.Message; import com.hannesdorfmann.mosby.mvp.MvpPresenter; public class MainPresenter implements MvpPresenter { MainModel mModel; private MainActivityFragment mView; public MainPresenter() { mModel = new MainModel(); } @Override public void attachView(MainActivityFragment view) { mView = view; view.setInitialState(); updateViewFromModel(); ensureModelDataIsLoaded(); } @Override public void detachView(boolean retainInstance) { mView = null; } private void ensureModelDataIsLoaded() { if (!mModel.isLoaded()) { mModel.loadAsync(new Handler.Callback() { @Override public boolean handleMessage(Message msg) { updateViewFromModel(); return true; } }); } } /** Notifies the views of the current value of "numberOfUsersLoggedIn", if any */ private void updateViewFromModel() { if (mView != null && mModel.isLoaded()) { mView.setNumberOfLoggedIn(mModel.numberOfUsersLoggedIn); } } }
MVP – MODEL
package com.nilzor.presenterexample; import android.os.AsyncTask; import android.os.Handler; import java.util.Random; public class MainModel { public Integer numberOfUsersLoggedIn; private boolean mIsLoaded; public boolean isLoaded() { return mIsLoaded; } public void loadAsync(final Handler.Callback onDoneCallback) { new AsyncTask() { @Override protected Void doInBackground(Void... params) { // Simulating some asynchronous task fetching data from a remote server try {Thread.sleep(2000);} catch (Exception ex) {}; numberOfUsersLoggedIn = new Random().nextInt(1000); mIsLoaded = true; return null; } @Override protected void onPostExecute(Void aVoid) { onDoneCallback.handleMessage(null); } }.execute((Void) null); } }
MVVM – VIEW – XML
<layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <data> <variable name="data" type="com.nilzor.presenterexample.MainModel"/> </data> <RelativeLayout android:layout_width="match_parent" android:layout_height="match_parent" android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingRight="@dimen/activity_horizontal_margin" android:paddingTop="@dimen/activity_vertical_margin" android:paddingBottom="@dimen/activity_vertical_margin" tools:context=".MainActivityFragment"> <TextView android:text="@{data.numberOfUsersLoggedIn}" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:id="@+id/loggedInUserCount"/> <TextView android:text="# logged in users:" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="false" android:layout_toLeftOf="@+id/loggedInUserCount"/> <RadioGroup android:layout_marginTop="40dp" android:id="@+id/existingOrNewUser" android:gravity="center" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerHorizontal="true" android:orientation="horizontal"> <RadioButton android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Returning user" android:checked="@{data.isExistingUserChecked}" android:id="@+id/returningUserRb"/> <RadioButton android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="New user" android:id="@+id/newUserRb" /> </RadioGroup> <LinearLayout android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="wrap_content" android:id="@+id/username_block" android:layout_below="@+id/existingOrNewUser"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="?android:attr/textAppearanceMedium" android:text="Username:" android:id="@+id/textView" android:minWidth="100dp"/> <EditText android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/username" android:minWidth="200dp"/> </LinearLayout> <LinearLayout android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_alignParentStart="false" android:id="@+id/password_block" android:layout_below="@+id/username_block"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="?android:attr/textAppearanceMedium" android:text="Password:" android:minWidth="100dp"/> <EditText android:layout_width="wrap_content" android:layout_height="wrap_content" android:inputType="textPassword" android:ems="10" android:id="@+id/password"/> </LinearLayout> <LinearLayout android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_below="@+id/password_block" android:id="@+id/email_block" android:visibility="@{data.emailBlockVisibility}"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="?android:attr/textAppearanceMedium" android:text="Email:" android:minWidth="100dp"/> <EditText android:layout_width="wrap_content" android:layout_height="wrap_content" android:inputType="textEmailAddress" android:ems="10" android:id="@+id/email"/> </LinearLayout> <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{data.loginOrCreateButtonText}" android:id="@+id/loginOrCreateButton" android:layout_below="@+id/email_block" android:layout_centerHorizontal="true"/> </RelativeLayout> </layout>
MVVM – VIEW – JAVA
package com.nilzor.presenterexample; import android.app.Fragment; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.CompoundButton; import android.widget.Toast; import com.nilzor.presenterexample.databinding.FragmentMainBinding; public class MainActivityFragment extends Fragment { private FragmentMainBinding mBinding; private MainModel mViewModel; public MainActivityFragment() { } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = inflater.inflate(R.layout.fragment_main, container, false); mBinding = FragmentMainBinding.bind(view); mViewModel = new MainModel(this, getResources()); mBinding.setData(mViewModel); attachButtonListener(); return view; } private void attachButtonListener() { mBinding.loginOrCreateButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { mViewModel.logInClicked(); } }); } @Override public void onViewCreated(View view, Bundle savedInstanceState) { ensureModelDataIsLodaded(); } private void ensureModelDataIsLodaded() { if (!mViewModel.isLoaded()) { mViewModel.loadAsync(); } } public void showShortToast(String text) { Toast.makeText(getActivity(), text, Toast.LENGTH_SHORT).show(); } }
MVVM – VIEWMODEL
package com.nilzor.presenterexample; import android.content.res.Resources; import android.databinding.ObservableField; import android.os.AsyncTask; import android.view.View; import java.util.Random; public class MainModel { public ObservableField numberOfUsersLoggedIn = new ObservableField(); public ObservableField isExistingUserChecked = new ObservableField(); public ObservableField emailBlockVisibility = new ObservableField(); public ObservableField loginOrCreateButtonText = new ObservableField(); private boolean mIsLoaded; private MainActivityFragment mView; private Resources mResources; public MainModel(MainActivityFragment view, Resources resources) { mView = view; mResources = resources; // You might want to abstract this for testability setInitialState(); updateDependentViews(); hookUpDependencies(); } public boolean isLoaded() { return mIsLoaded; } private void setInitialState() { numberOfUsersLoggedIn.set("..."); isExistingUserChecked.set(true); } private void hookUpDependencies() { isExistingUserChecked.addOnPropertyChangedCallback(new android.databinding.Observable.OnPropertyChangedCallback() { @Override public void onPropertyChanged(android.databinding.Observable sender, int propertyId) { updateDependentViews(); } }); } public void updateDependentViews() { if (isExistingUserChecked.get()) { emailBlockVisibility.set(View.GONE); loginOrCreateButtonText.set(mResources.getString(R.string.log_in)); } else { emailBlockVisibility.set(View.VISIBLE); loginOrCreateButtonText.set(mResources.getString(R.string.create_user)); } } public void loadAsync() { new AsyncTask() { @Override protected Void doInBackground(Void... params) { // Simulating some asynchronous task fetching data from a remote server try {Thread.sleep(2000);} catch (Exception ex) {}; numberOfUsersLoggedIn.set("" + new Random().nextInt(1000)); mIsLoaded = true; return null; } }.execute((Void) null); } public void logInClicked() { // Illustrating the need for calling back to the view though testable interfaces. if (isExistingUserChecked.get()) { mView.showShortToast("Invalid username or password"); } else { mView.showShortToast("Please enter a valid email address"); } } }