[Huawei Developers] muraliameakula:
In this tutorial, we will be discussing and implementing the HarmonyOS MVVM Architectural pattern in our Harmony app.

This project is available on github, link can be found at the end of the article

Table of contents

What is MVVM

Harmony MVVM example project structure

Adding dependencies

Model

Layout

Retrofit interface

ViewModel

Tip and Tricks

Conclusion

Recommended resources

What is MVVM

MVVM stands for Model, View, ViewModel:

Model: This holds the data of the application. It cannot directly talk to the View. Generally, it’s recommended to expose the data to the ViewModel through ActiveDatas (Observables ).

View: It represents the UI of the application devoid of any Application Logic. It observes the ViewModel.

ViewModel: It acts as a link between the Model and the View. It’s responsible for transforming the data from the Model. It provides data streams to the View. It also uses hooks or callbacks to update the View. It’ll ask for the data from the Model.

MVVM can be achieved in two ways:

Using Data binding

RxJava

In this tutorial we will implement MVVM in Harmony using RXjava, as Data binding is still under development and not ready to use in Harmony.

Harmony MVVM example project structure​

We will create packages by features. It will make your code more modular and manageable.

Adding the Dependencies

Add the following dependencies in your module level build.gradle file:

dependencies {
//[...]

//RxJava
implementation "io.reactivex.rxjava2:rxjava:2.2.17"

//retrofit
implementation 'com.squareup.retrofit2:retrofit:2.6.0'
implementation "com.squareup.retrofit2:converter-moshi:2.6.0"
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

//RxJava adapter for retrofit
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.7.1'

}

Model

The Model would hold the user’s email and password. The following User.java class does it:

package com.megaache.mvvmdemo.model;

public class User {

private String email;
private String password;

public User(String email, String password) {
this.email = email;
this.password = password;
}

public void setEmail(String email) {
this.email = email;
}

public String getEmail() {
return email;
}

public void setPassword(String password) {
this.password = password;
}

public String getPassword() {
return password;
}

@Override
public String toString() {
return "User{" +
"email='" + email + '\'' +
", password='" + password + '\'' +
'}';
}
}

Layout

NOTE: For this tutorial, I have decided to create the layout for smart watch devices, however it will work fine on all devices, you just need to re-arrange the components and modify the alignment.

The layout will consist of login button, two text fields and two error texts, each will be shown or hidden depending on the value of the text box above it, after clicking the login button. the final UI will like the screenshot below:

Before we create layout lets add some colors:

First create file color.json under resources/base/element and add the following json content:

{
"color": [
{
"name": "primary",
"value": "#283148"
},
{
"name": "primaryDark",
"value": "#283148"
},
{
"name": "accent",
"value": "#06EBBF"
},
{
"name": "red",
"value": "#FF406E"
}
]
}

Then, lets design background elements for the Text Fields and the Button:

Create file background_text_field.xml and background_text_button.xml under resources/base/graphic as shown in below screenshot :

Then add the following code:

Background_text_field.xml:

<?xml version="1.0" encoding="UTF-8" ?>
<shape
xmlns:ohos="http://schemas.huawei.com/res/ohos"
ohos:shape="rectangle">
<corners
ohos:radius="20"/>
<solid
ohos:color="#ffffff"/>
<stroke
ohos:width="2"
ohos:color="$color:accent"/>
</shape>

Background_button.xml:

<?xml version="1.0" encoding="UTF-8" ?>
<shape
xmlns:ohos="http://schemas.huawei.com/res/ohos"
ohos:shape="rectangle">
<corners
ohos:radius="20"/>
<solid
ohos:color="$color:accent"/>
</shape>

Now lets create the background element for the main layout, let’s called background_ability_login.xml:

<?xml version="1.0" encoding="UTF-8" ?>
<shape xmlns:ohos="http://schemas.huawei.com/res/ohos"
ohos:shape="rectangle">
<solid
ohos:color="$color:primaryDark"/>
</shape>

Finally, let’s create the layout file ability_login.xml:

<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:ohos="http://schemas.huawei.com/res/ohos"
ohos:id="$+id:scrollview"
ohos:height="match_parent"
ohos:width="match_parent"
ohos:background_element="$graphic:background_ability_login"
ohos:layout_alignment="horizontal_center"
ohos:rebound_effect="true"
>

<DirectionalLayout
ohos:height="match_content"
ohos:width="match_parent"
ohos:orientation="vertical"
ohos:padding="20vp"
>

<DirectionalLayout
ohos:height="match_content"
ohos:width="match_parent"
ohos:layout_alignment="center"
ohos:orientation="vertical"
>

<TextField
ohos:id="$+id:tf_email"
ohos:height="match_content"
ohos:width="match_parent"
ohos:background_element="$graphic:background_text_field"
ohos:hint="email"
ohos:left_padding="10vp"
ohos:min_height="40vp"
ohos:multiple_lines="false"
ohos:text_alignment="vertical_center"
ohos:text_color="black"
ohos:text_input_type="pattern_number"
ohos:text_size="15fp"/>

<Text
ohos:id="$+id:t_email_invalid"
ohos:height="match_content"
ohos:width="match_content"
ohos:layout_alignment="center"
ohos:text="invalid email"
ohos:text_color="$color:red"
ohos:text_size="15fp"
/>
</DirectionalLayout>

<DirectionalLayout
ohos:height="match_content"
ohos:width="match_parent"
ohos:layout_alignment="center"
ohos:orientation="vertical"
ohos:top_margin="10vp">

<TextField
ohos:id="$+id:tf_password"
ohos:height="match_content"
ohos:width="match_parent"
ohos:background_element="$graphic:background_text_field"
ohos:hint="password"
ohos:left_padding="10vp"
ohos:min_height="40vp"
ohos:multiple_lines="false"
ohos:text_alignment="vertical_center"
ohos:text_color="black"
ohos:text_input_type="pattern_password"
ohos:text_size="15fp"
/>

<Text
ohos:id="$+id:t_password_invalid"
ohos:height="match_content"
ohos:width="match_content"
ohos:layout_alignment="center"
ohos:padding="0vp"
ohos:text="invalid password"
ohos:text_color="$color:red"
ohos:text_size="15fp"
/>
</DirectionalLayout>

<Button
ohos:id="$+id:btn_login"
ohos:height="match_content"
ohos:width="match_parent"
ohos:background_element="$graphic:background_button"
ohos:bottom_margin="30vp"
ohos:min_height="40vp"
ohos:text="login"
ohos:text_color="#fff"
ohos:text_size="18fp"
ohos:top_margin="10vp"/>

</DirectionalLayout>
</ScrollView>

Retrofit interface

Before we move to the ViewModel, we have to setup our Retrofit service and repository class.

To keep the project clean, I will create class config.java which will hold our API URLs:

package com.megaache.mvvmdemo;

public class Config {
//todo: update base url variable with valid url
public static final String BASE_URL = "https://example.com";
public static final String API_VERSION = "/api/v1";
public static final String LOGIN_URL="auth/login";
}

Note: The url's are just for demonstration. For the demo to work, you must replace the urls.

First create interface APIServices.java:

For this tutorial, we assume the method of login EndPoint is Post, you may changes depending on your API, the method login will return an Observable, that will be observed in the ViewModel using RxJava.

package com.megaache.mvvmdemo.network;

import com.megaache.mvvmdemo.Config;
import com.megaache.mvvmdemo.network.request.LoginRequest;
import com.megaache.mvvmdemo.network.response.LoginResponse;
import io.reactivex.Observable;
import retrofit2.http.Body;
import retrofit2.http.Headers;
import retrofit2.http.POST;

public interface APIServices {
@POST(Config.LOGIN_URL)
@Headers("Content-Type: application/json;charset=UTF-8")
public Observable<LoginResponse> login(@Body LoginRequest loginRequest);

}

Note: the class LoginRequest which you will see later in this tutorial, must be equal to the request that the server expects in The names of variables and their types, otherwise the server will fail to process the request.

Then, add method createRetrofitClient() to MyApplication.java, which will create and return retrofit instance, the instance will use Moshi converter to handle the conversion of JSON to our java class, and RxJava2 adapter to return observables that can work with RxJava instead of the default Call class which requires callbacks:

package com.megaache.mvvmdemo;

import com.megaache.mvvmdemo.network.APIServices;
import ohos.aafwk.ability.AbilityPackage;
import okhttp3.OkHttpClient;
import retrofit2.Retrofit;
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory;
import retrofit2.converter.moshi.MoshiConverterFactory;

import java.util.concurrent.TimeUnit;

public class MyApplication extends AbilityPackage {
@Override
public void onInitialize() {
super.onInitialize();
}

public static APIServices createRetrofitClient() {
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(60L, TimeUnit.SECONDS)
.build();
Retrofit retrofit = new Retrofit.Builder()
.baseUrl(Config.BASE_URL + Config.API_VERSION)
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.addConverterFactory(MoshiConverterFactory.create()).client(client)
.build();
return retrofit.create(APIServices.class);
}

}

NOTE: For cleaner code, you can create file RetrofitClient.java and move the method createRetrofitClient() to it.

Now, let’s work on the Login feature, we going to first create request and response classes, then move to the ViewModel and the view:

We need LoginRequest and LoginResponse which both will extends BaseRequest and BaseRespnonse, code is shown below:

Create BaseRequest.java:

In real life project, your API may expect some parameter to be sent with every request. For example: accessToken, language, deviceId, pushToken etc. which will depende on your API. for this tutorial I added one field called deviceType with static value.

package com.megaache.mvvmdemo.network.request;

public class BaseRequest {

private String deviceType;

public BaseRequest() {
deviceType = "harmony-watch";
}

public String getDeviceType() {
return deviceType;
}

public void setDeviceType(String deviceType) {
this.deviceType = deviceType;
}
}

Create class LoginRequest.java, which will extend BaseRequest and have two fields Email and Password, which will provided by the end user:

package com.megaache.mvvmdemo.network.request;

public class LoginRequest {
private String email;
private String password;

public String getEmail() {
return email;
}

public void setEmail(String email) {
this.email = email;
}

public String getPassword() {
return password;
}

public void setPassword(String password) {
this.password = password;
}
}

Then for the response, Create BaseResponse.java first:

package com.megaache.mvvmdemo.network.response;

import com.megaache.mvvmdemo.MyApplication;

import java.io.Serializable;

public class BaseResponse implements Serializable {

}

Then LoginResponse.java extending BaseResponse:

package com.megaache.mvvmdemo.network.response;

import com.megaache.mvvmdemo.model.User;
import com.squareup.moshi.Json;

public class LoginResponse extends BaseResponse {
@Json(name = "user")
private User user;
@Json(name = "accessToken")
private String accessToken;

public User getUser() {
return user;
}

public void setUser(User user) {
this.user = user;
}

public String getAccessToken() {
return accessToken;
}

public void setAccessToken(String accessToken) {
this.accessToken = accessToken;
}
}

Note: this class must be equal to the response you get from server, otherwise Retrofit Gson converter will fail to convert the response to LoginResponse class, both the type of variables and their names must equal the those in the JSON response.

ViewModel

In ViewModel, we will wrap the data which was loaded with Retrofit inside class LoggedIn in LoginViewState, and observe states Observable defined in BaseViewModel in our Ability (or AbilitySlice). Whenever the value in states changes, the ability will be notified without checking whether the ability is alive or not.

The code for LoginViewState.java extending empty class BaseViewState.java, and ErrorData.java (used in LoginViewState.java) is given below:

ErrorData.java:

package com.megaache.mvvmdemo.model;

import java.io.Serializable;

public class ErrorData implements Serializable {
private String message;
private int statusCode;

public String getMessage() {
return message;
}

public void setMessage(String message) {
this.message = message;
}

public int getStatusCode() {
return statusCode;
}

public void setStatusCode(int statusCode) {
this.statusCode = statusCode;
}
}

LoginViewState.java:

package com.megaache.mvvmdemo.ui.login;

import com.megaache.mvvmdemo.base.BaseViewState;
import com.megaache.mvvmdemo.model.ErrorData;
import com.megaache.mvvmdemo.network.response.LoginResponse;

public class LoginViewState extends BaseViewState {
public static class Loading extends LoginViewState {
}

public static class Error extends LoginViewState {
private ErrorData message;

public Error(ErrorData message) {
this.message = message;
}

public void setMessage(ErrorData message) {
this.message = message;
}

public ErrorData getMessage() {
return message;
}
}

public static class LoggedIn extends LoginViewState {
private LoginResponse userDataResponse;

public LoggedIn(LoginResponse userDataResponse) {
this.userDataResponse = userDataResponse;
}

public LoginResponse getUserDataResponse() {
return userDataResponse;
}

public void setUserDataResponse(LoginResponse userDataResponse) {
this.userDataResponse = userDataResponse;
}
}

}

The code for the LoginViewModel.java is given below:

When the user clicks the login button, the method sendLoginRequest() will setup our retrofit Observable, the request will not be sent until the we call the method subscribe which will be done on the View. notice we are subscribing on the Schedulers.Io() Scheduler, which is will execute the requests in a background thread to avoid freezing the UI, and because of that we have to create our custom Observer that will invoke the callback code in UI thread after we receive data, more on this later:

package com.megaache.mvvmdemo.ui.login;

import com.megaache.mvvmdemo.base.BaseViewModel;
import com.megaache.mvvmdemo.MyApplication;
import com.megaache.mvvmdemo.model.ErrorData;
import com.megaache.mvvmdemo.network.request.LoginRequest;
import io.reactivex.Observable;
import io.reactivex.schedulers.Schedulers;
import ohos.aafwk.abilityjet.activedata.ActiveData;

public class LoginViewModel extends BaseViewModel<LoginViewState> {

private static final int MIN_PASSWORD_LENGTH = 6;

public ActiveData<Boolean> emailValid = new ActiveData<>();
public ActiveData<Boolean> passwordValid = new ActiveData<>();
public ActiveData<Boolean> loginState = new ActiveData<>();

public LoginViewModel() {
super();
}

public void login(String email, String password) {

boolean isEmailValid = isEmailValid(email);
emailValid.setData(isEmailValid);
if (!isEmailValid)
return;

boolean isPasswordValid = isPasswordValid(email);
passwordValid.setData(isPasswordValid);
if (!isPasswordValid)
return;

LoginRequest loginRequest = new LoginRequest();
loginRequest.setEmail(email);
loginRequest.setPassword(password);

super.subscribe(sendLoginRequest(loginRequest));
}

private Observable<LoginViewState> sendLoginRequest(LoginRequest loginRequest) {
return MyApplication.createRetrofitClient()
.login(loginRequest)
.doOnError(Throwable::printStackTrace)
.map(LoginViewState.LoggedIn::new)
.cast(LoginViewState.class)
.onErrorReturn(throwable -> {
ErrorData errorData = new ErrorData();
if (throwable.getMessage() != null)
errorData.setMessage(throwable.getMessage());
else
errorData.setMessage(" No internet! ");
return new LoginViewState.Error(errorData);
})
.subscribeOn(Schedulers.io())
.startWith(new LoginViewState.Loading());
}

private boolean isEmailValid(String email) {
return email != null && !email.isEmpty() && email.contains("@");
}

private boolean isPasswordValid(String password) {
return password != null && password.length() > MIN_PASSWORD_LENGTH;
}
}

Settings up the ability (View)

As you know, ability is our view, we have instantiated ViewModel and observer states and ActiveDatas in the method ObserverData(), as mentioned before, retrofit will send the request on background thread, therefore the code in the Observer will run on the same thread (Schedulars.io()), which will cause exceptions if that code attemp to update the UI, to prevent that, we will create a custom UIObserver class which extends Observer, that will run our code in the UI task dispatcher of the ability (UI Thread), code for UiObserver.java as show below:

package com.megaache.mvvmdemo.utils;

import ohos.aafwk.ability.Ability;
import ohos.aafwk.ability.AbilitySlice;
import ohos.aafwk.abilityjet.activedata.DataObserver;
import ohos.app.dispatcher.TaskDispatcher;

public abstract class UiObserver<T> extends DataObserver<T> {

private TaskDispatcher uiTaskDispatcher;

public UiObserver(Ability baseAbilitySlice) {
setLifecycle(baseAbilitySlice.getLifecycle());
uiTaskDispatcher = baseAbilitySlice.getUITaskDispatcher();
}

@Override
public void onChanged(T t) {
uiTaskDispatcher.asyncDispatch(() -> onValueChanged(t));
}

public abstract void onValueChanged(T t);
}

Code for LoginAbility.java is shown below:

package com.megaache.mvvmdemo.ui.login;

import com.megaache.mvvmdemo.ResourceTable;
import com.megaache.mvvmdemo.utils.UiObserver;
import com.megaache.mvvmdemo.model.ErrorData;
import ohos.aafwk.ability.Ability;
import ohos.aafwk.content.Intent;
import ohos.agp.components.Button;
import ohos.agp.components.Component;
import ohos.agp.components.Text;
import ohos.agp.components.TextField;
import ohos.agp.window.dialog.ToastDialog;

public class LoginAbility extends Ability {

private LoginViewModel loginViewModel;

private TextField emailTF;
private Text emailInvalidT;

private TextField passwordTF;
private Text passwordInvalidT;

@Override
public void onStart(Intent intent) {
super.onStart(intent);
loginViewModel = new LoginViewModel();

initUI();
observeData();
}

private void initUI() {
super.setUIContent(ResourceTable.Layout_ability_login);

Button loginButton = (Button) findComponentById(ResourceTable.Id_btn_login);
loginButton.setClickedListener(c -> attemptLogin());

emailTF = (TextField) findComponentById(ResourceTable.Id_tf_email);
emailInvalidT = (Text) findComponentById(ResourceTable.Id_t_email_invalid);

passwordTF = (TextField) findComponentById(ResourceTable.Id_tf_password);
passwordInvalidT = (Text) findComponentById(ResourceTable.Id_t_password_invalid);

}

private void observeData() {
loginViewModel.emailValid.addObserver(new UiObserver<Boolean>(this) {
@Override
public void onValueChanged(Boolean aBoolean) {
emailInvalidT.setVisibility(aBoolean ? Component.VISIBLE : Component.HIDE);
}
}, false);

loginViewModel.passwordValid.addObserver(new UiObserver<Boolean>(this) {
@Override
public void onValueChanged(Boolean aBoolean) {
passwordInvalidT.setVisibility(aBoolean ? Component.VISIBLE : Component.HIDE);
}
}, false);

loginViewModel.getStates().addObserver(new UiObserver<LoginViewState>(this) {
@Override
public void onValueChanged(LoginViewState loginState) {
if (loginState instanceof LoginViewState.Loading) {
toggleLoadingDialog(true);
} else if (loginState instanceof LoginViewState.Error) {
toggleLoadingDialog(false);
manageError(((LoginViewState.Error) loginState).getMessage());
} else if (loginState instanceof LoginViewState.LoggedIn) {
toggleLoadingDialog(false);
showToast("logging successful!");
}
}
}, false);
}

private void attemptLogin() {
loginViewModel.login(emailTF.getText(), passwordTF.getText());
}

private void toggleLoadingDialog(boolean show) {
//todo: show/hide loading dialog
}

private void manageError(ErrorData errorData) {
showToast(errorData.getMessage());
}

private void showToast(String message) {
new ToastDialog(this)
.setText(message)
.show();
}

@Override
protected void onStop() {
super.onStop();
loginViewModel.unbind();
}
}

Tips And Tricks

If you want your app to work offline, its best to introduce a Repository classes that will handle quering information from server if internet is available or from the cach if not

For cleaner code, try re-using the ViewModel as much as possible, by creating a base class and moving the shared code their

You should not keep a reference to a View (component) or context in the ViewModel, unless you have no option

The ViewModel should not talk directly to the View, instead the View obseve the ViewModel and update itself depending on ViewModel data

A correct implementation of ViewModel should allow you to change the UI with minimal or zero changes to the ViewModel.

Conclusion

MVVM combines the advantages of separation of concerns provided by MVP archichetecture, while leveraging the advantages of RxJava or Data binding. The result is a pattern where the model drives as many of the operations as possible, minimizing the logic in the view.

Finally, talk is cheap, and I strongly advise you to try and learn these things in the code so that you do not need to rely on people like me to tell you what to do.

Clone the project from github, replate the API Urls in class config.java and run it on HarmonyOs Device, you should see a toast that says "Logging succesful" if the credentials are correct, otherwise it should show a toast with the error that says "no internet" or and error returned from the server.

This project is available on GitHub: Click here

Recommended sources:

HarmonyOS (essential topics): Essential Topics

Retrofit: Click here

Rxjava: Click here

Original Source

Comment below if you have any questions or suggestions.

Thank you!