Android binding: Thay thế Presenter bởi ViewModel
Mô hình Model-View-Presenter đang là xu hướng phổ biến khi nói tới kiến trúc phân tầng UI trong phát triển ứng dụng Android. Các framework như Ted Mosby, Nucleus và Mortar đều nói về Presenters để giúp chúng ta hiểu rõ hơn về kiến trúc để phát triển ứng dụng. Ở một mức độ nào đó, chúng cũng giúp ...
Mô hình Model-View-Presenter đang là xu hướng phổ biến khi nói tới kiến trúc phân tầng UI trong phát triển ứng dụng Android. Các framework như Ted Mosby, Nucleus và Mortar đều nói về Presenters để giúp chúng ta hiểu rõ hơn về kiến trúc để phát triển ứng dụng. Ở một mức độ nào đó, chúng cũng giúp bạn những vấn đề quay thiết bị (device rotation) và duy trì trạng thái (state persistence) trên nền tảng Android. Những vấn đề này không trực tiếp liên quan đến khái niệm của MVP, nhưng mô hình này giúp bạn cô lập được các mã mang tính đặc thù của Android.
Data Binding, được công bố trong sự kiện Google I/O 2015, và được đưa vào sử dụng trong Android M preview như một thư viện hỗ trợ (support library), thay đổi mọi thứ. Theo bài viết trên Wikipedia về MVP, Presenter có các nhiệm vụ sau:
The presenter acts upon the model and the view. It retrieves data from repositories (the model), and formats it for display in the view.
Tức là, Presenter hoạt động giữa Model và View. Nó lấy dữ liệu từ Model, và định dạng lại dữ liệu để hiển thị được trên View.
Giờ đây, Data Binding framework sẽ đảm nhận công việc chính của Presenter ("acting upon the model and the view"), trong khi đó trách nhiệm còn lại (“retreiving data from repositories and formatting”) sẽ được đưa vào thành phần model nâng cao - gọi là ViewModel. ViewModel là một lớp Java tiêu chuẩn (standard Java class) có trách nhiệm duy nhất là đại diện cho dữ liệu đằng sau một View đơn. Nó có thể kết hợp dữ liệu từ nhiều nguồn (nhiều Model) để chuẩn bị dữ liệu cho việc hiển thị.
Cách tiếp cận này, hình thành nên mô hình kiến trúc Model-View-ViewModel (MVVM), sự thay đổi từ mô hình MVP sang mô hình MVVM được minh hoạ qua hình sau:
Vì vậy, tất cả các ràng buộc (binding) và cập nhật (updating) dữ liệu đến View được thực hiện thông qua Data Binding framework. Lớp ObservableField cho phép View phản ứng với các thay đổi trong Model, và các tham chiếu trong file XML cho phép framework đẩy các thay đổi về ViewModel khi người dùng thực hiện hành động lên trên View.
Lưu ý rằng trong hình minh hoạ mô hình MVP phía trên có phương thức gọi tới Presenter.loadUsers(). Đây là một Command. Trong mô hình MVVM, đây là những phương thức được định nghĩa trong ViewModel. Theo bài viết trên Wikipedia:
The view model is an abstraction of the view that exposes public properties and commands.
Tức là ViewModel là một sự trừu tượng (abstraction) của View mà nó thể hiện các public property và command.
Vì vậy, điều này có thể hoặc không là một sự thay đổi những gì mà bạn đang sử dụng. Trong mô hình MVP, nó giống như các model là các lớp "dumb" chỉ giữ dữ liệu. Đừng lo lắng khi đưa các business logic vào Model và ViewModel. Đây là một nguyên tắc cốt lõi của lập trình hướng đối tượng (OOP - Object Oriented Programming).
Trở lại phương thức Presenter.loadUsers() - bây giờ là phương thức trong ViewModel, có thể được gọi từ code-behind của View hoặc thông qua một command ràng buộc dữ liệu trong XML của View. Nếu không sử dụng data binding với các command, chúng ta phải dùng đến các cú pháp cũ android:onClick hoặc thêm các lắng nghe (listener) vào View như trước đây.
- View trong Android bao gồm 2 yếu tố đại diện: View Layout (XML) và Code-Behind (Java) - đại diện bởi các Fragment, Activity và các lớp kế thừa từ View.java.
Dealing with system calls
Có một tập hợp các trường hợp bắt buộc phải được cài đặt ở Code-Behind của View - đó là các chức năng lời gọi hệ thống. Chẳng hạn mở một dialog hoặc đơn giản là bất kỳ lời gọi nào mà yêu cầu tham chiếu tới đối tượng Context của Android. Đừng đặt những mã code như vậy vào trong ViewModel. Nếu lớp ViewModel mà có chứa dòng lệnh import android.content.Context;, bạn đang cài đặt sai, không nên làm như vậy.
Có một vài sự lựa chọn tốt trong trường hợp này. Một cách là giữ lại các yếu tố của khái niệm Presenter bằng cách sử dụng các interface tham chiếu tới View trong ViewModel. Cách này sẽ không làm giảm khả năng kiểm thử được. Nhưng thay vì có một lớp Presenter riêng biệt, ta sẽ thực hiện gắn vào View như một cài đặt cụ thể của interface đó để giữ nó đơn giản. Một cách tiếp cận khác là sử dụng event bus để khởi tạo các command như new ShowToastMessage("hello world"). Điều này mang lại sự tách biệt lớn hơn giữa View và ViewModel.
Tổng kết
Chúng ta sử dụng mô hình MVVM để tận dụng tốt nhất Data Binding.
Code example
MVP – VIEW – XML<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_awidth="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_awidth="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:id="@+id/loggedInUserCount"/> <TextView android:text="# logged in users:" android:layout_awidth="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_awidth="wrap_content" android:layout_height="wrap_content" android:layout_centerHorizontal="true" android:orientation="horizontal"> <RadioButton android:layout_awidth="wrap_content" android:layout_height="wrap_content" android:text="Returning user" android:id="@+id/returningUserRb"/> <RadioButton android:layout_awidth="wrap_content" android:layout_height="wrap_content" android:text="New user" android:id="@+id/newUserRb" /> </RadioGroup> <LinearLayout android:orientation="horizontal" android:layout_awidth="match_parent" android:layout_height="wrap_content" android:id="@+id/username_block" android:layout_below="@+id/existingOrNewUser"> <TextView android:layout_awidth="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_awidth="wrap_content" android:layout_height="wrap_content" android:id="@+id/username" android:minWidth="200dp"/> </LinearLayout> <LinearLayout android:orientation="horizontal" android:layout_awidth="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_awidth="wrap_content" android:layout_height="wrap_content" android:textAppearance="?android:attr/textAppearanceMedium" android:text="Password:" android:minWidth="100dp"/> <EditText android:layout_awidth="wrap_content" android:layout_height="wrap_content" android:inputType="textPassword" android:ems="10" android:id="@+id/password"/> </LinearLayout> <LinearLayout android:orientation="horizontal" android:layout_awidth="match_parent" android:layout_height="wrap_content" android:layout_below="@+id/password_block" android:id="@+id/email_block"> <TextView android:layout_awidth="wrap_content" android:layout_height="wrap_content" android:textAppearance="?android:attr/textAppearanceMedium" android:text="Email:" android:minWidth="100dp"/> <EditText android:layout_awidth="wrap_content" android:layout_height="wrap_content" android:inputType="textEmailAddress" android:ems="10" android:id="@+id/email"/> </LinearLayout> <Button android:layout_awidth="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
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
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
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_awidth="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_awidth="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:id="@+id/loggedInUserCount"/> <TextView android:text="# logged in users:" android:layout_awidth="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="false" android:layout_toLeftOf="@+id/loggedInUserCount"/>