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 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:

mvp.png

mvvm.png

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_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
    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_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
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
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");
        }
    }
}

Tài liệu tham khảo:

https://developer.android.com/topic/libraries/data-binding/index.html

http://hannesdorfmann.com/android/mosby

http://tech.vg.no/2015/07/17/android-databinding-goodbye-presenter-hello-viewmodel/