Pull to refresh

Android Architecture Components. Часть 4. ViewModel

Reading time 11 min
Views 80K
image

Компонент ViewModel — предназначен для хранения и управления данными, связанными с представлением, а заодно, избавить нас от проблемы, связанной с пересозданием активити во время таких операций, как переворот экрана и т.д. Не стоит его воспринимать, как замену onSaveInstanceState, поскольку, после того как система уничтожит нашу активити, к примеру, когда мы перейдем в другое приложение, ViewModel будет также уничтожен и не сохранит свое состояние. В целом же, компонент ViewModel можно охарактеризовать как синглтон с колекцией экземпляров классов ViewModel, который гарантирует, что не будет уничтожен пока есть активный экземпляр нашей активити и освободит ресурсы после ухода с нее (все немного сложнее, но выглядит как-то так). Стоит также отметить, что мы можем привязать любое количество ViewModel к нашей Activity(Fragment).

Компонент состоит из таких классов: ViewModel, AndroidViewModel, ViewModelProvider, ViewModelProviders, ViewModelStore, ViewModelStores. Разработчик будет работать только с  ViewModel, AndroidViewModel и для получения истанца с ViewModelProviders, но для лучшего понимания компонента, мы поверхностно рассмотрим все классы.

Класс ViewModel, сам по себе представляет абстрактный класс, без абстрактных методов и с одним protected методом onCleared(). Для реализации собственного ViewModel, нам всего лишь необходимо унаследовать свой класс от ViewModel с конструктором без параметров и это все. Если же нам нужно очистить ресурсы, то необходимо переопределить метод onCleared(), который будет вызван когда ViewModel долго не доступна и должна быть уничтожена. Как пример, можно вспомнить предыдущую статью про LiveData, а конкретно о методе observeForever(Observer), который требует явной отписки, и как раз в методе onCleared() уместно ее реализовать. Стоит еще добавить, что во избежания утечки памяти, не нужно ссылаться напрямую на View или Context Activity из ViewModel. В целом, ViewModel должна быть абсолютно изолированная от представления данных. В таком случае появляется вопрос: А каким же образом нам уведомить представление (Activity/Fragment) об изменениях в наших данных? В этом случае на помощь нам приходит LiveData, все изменяемые данные мы должны хранить с помощью LiveData, если же нам необходимо, к примеру, показать и скрыть ProgressBar, мы можем создать MutableLiveData и хранить логику показать\скрыть в компоненте ViewModel. В общем это будет выглядеть так:

public class MyViewModel extends ViewModel {
  private MutableLiveData<Boolean> showProgress = new MutableLiveData<>();

  //new thread
  public void doSomeThing(){
      showProgress.postValue(true);
      ...
      showProgress.postValue(false);
  }
 
  public MutableLiveData<Boolean> getProgressState(){
      return showProgress;
  }
}

Для получения ссылки на наш экземпляр ViewModel мы должны воспользоваться ViewModelProviders:

@Override
protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_main);
  final MyViewModel viewModel = ViewModelProviders.of(this).get(MyViewModel.class);
  viewModel.getProgressState().observe(this, new Observer<Boolean>() {
      @Override
      public void onChanged(@Nullable Boolean aBoolean) {
          if (aBoolean) {
              showProgress();
          } else {
              hideProgress();
          }
      }
  });
  viewModel.doSomeThing();
}

Класс AndroidViewModel, являет собой расширение ViewModel, с единственным отличием — в конструкторе должен быть один параметр Application. Является довольно полезным расширением в случаях, когда нам нужно использовать Location Service или другой компонент, требующий Application Context. В работе с ним единственное отличие, это то что мы наследуем наш ViewModel от ApplicationViewModel. В Activity/Fragment инициализируем его точно также, как и обычный ViewModel.

Класс ViewModelProviders, являет собой четыре метода утилиты, которые, называются of и возвращают ViewModelProvider. Адаптированные для работы с Activity и Fragment, а также, с возможностью подставить свою реализацию ViewModelProvider.Factory, по умолчанию используется DefaultFactory, которая является вложенным классом в ViewModelProviders. Пока что других реализаций приведенных в пакете android.arch нет.

Класс ViewModelProvider, собственно говоря класс, который возвращает наш инстанс ViewModel. Не будем особо углубляться здесь, в общих чертах он являет роль посредника с ViewModelStore, который, хранит и поднимает наш интанс ViewModel и возвращает его с помощью метода get, который имеет две сигнатуры get(Class) и get(String key, Class modelClass). Смысл заключается в том, что мы можем привязать несколько ViewModel к нашему Activity/Fragment даже одного типа. Метод get возвращает их по String key, который по умолчанию формируется как: «android.arch.lifecycle.ViewModelProvider.DefaultKey:» + canonicalName

Класс ViewModelStores, являет собой фабричный метод, напомню: Фабричный метод — паттерн, который определяет интерфейс для создания объекта, но оставляет подклассам решение о том, какой класс инстанцировать, по факту, позволяет классу делегировать инстанцирование подклассам. На данный момент, в пакете android.arch присутствует как один интерфейс, так и один подкласс ViewModelStore.

Класс ViewModelStore, класс в котором и находится вся магия, состоит из методов put, get и clear. Про них не стоит беспокоится, поскольку работать напрямую мы с ними не должны, а с get и put и физически не можем, так как они объявлены как default (package-private), соответственно видны только внутри пакета. Но, для общего образования, рассмотрим устройство этого класса. Сам класс хранит в себе HashMap<String, ViewModel>, методы get и put, соответственно, возвращают по ключу (по тому самому, который мы формируем во ViewModelProvider) или добавляют ViewModel. Метод clear(), вызовет метод onCleared() у всех наших ViewModel которые мы добавляли.

Для примера работы с ViewModel давайте реализуем небольшое приложение, позволяющее выбрать пользователю точку на карте, установить радиус и показывающее, находится человек в этом поле или нет. А также дающее возможность указать WiFi network, если пользователь подключен к нему, будем считать что он в радиусе, вне зависимости от физических координат.


Для начала создадим две LiveData для отслеживания локации и имени WiFi сети:

public class LocationLiveData extends LiveData<Location> implements
      GoogleApiClient.ConnectionCallbacks,
      GoogleApiClient.OnConnectionFailedListener,
      LocationListener {
  private final static int UPDATE_INTERVAL = 1000;
  private GoogleApiClient googleApiClient;

  public LocationLiveData(Context context) {
      googleApiClient =
              new GoogleApiClient.Builder(context, this, this)
                      .addApi(LocationServices.API)
                      .build();
  }

  @Override
  protected void onActive() {
      googleApiClient.connect();
  }

  @Override
  protected void onInactive() {
      if (googleApiClient.isConnected()) {
          LocationServices.FusedLocationApi.removeLocationUpdates(
                  googleApiClient, this);
      }
      googleApiClient.disconnect();
  }

  @Override
  public void onConnected(Bundle connectionHint) {
          LocationRequest locationRequest = new LocationRequest().setInterval(UPDATE_INTERVAL).setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);
          LocationServices.FusedLocationApi.requestLocationUpdates(
                  googleApiClient, locationRequest, this);
  }

  @Override
  public void onLocationChanged(Location location) {
      setValue(location);
  }

  @Override
  public void onConnectionSuspended(int cause) {
      setValue(null);
  }

  @Override
  public void onConnectionFailed(ConnectionResult connectionResult) {
      setValue(null);
  }
}


public class NetworkLiveData extends LiveData<String> {
  private Context context;
  private BroadcastReceiver broadcastReceiver;

  public NetworkLiveData(Context context) {
      this.context = context;
  }

  private void prepareReceiver(Context context) {
      IntentFilter filter = new IntentFilter();
      filter.addAction("android.net.wifi.supplicant.CONNECTION_CHANGE");
      filter.addAction("android.net.wifi.STATE_CHANGE");
      broadcastReceiver = new BroadcastReceiver() {
          @Override
          public void onReceive(Context context, Intent intent) {
              WifiManager wifiMgr = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
              WifiInfo wifiInfo = wifiMgr.getConnectionInfo();
              String name = wifiInfo.getSSID();
              if (name.isEmpty()) {
                  setValue(null);
              } else {
                  setValue(name);
              }
          }
      };
      context.registerReceiver(broadcastReceiver, filter);
  }

  @Override
  protected void onActive() {
      super.onActive();
      prepareReceiver(context);
  }

  @Override
  protected void onInactive() {
      super.onInactive();
      context.unregisterReceiver(broadcastReceiver);
      broadcastReceiver = null;
  }
}

Теперь перейдем к ViewModel, поскольку у нас есть условие, которое зависит от полученных данных с двух LifeData, нам идеально подойдет MediatorLiveData как холдер самого значения, но поскольку перезапускать сервисы нам невыгодно, поэтому подпишемся к MediatorLiveData без привязки к жизненному циклу с помощью observeForever.  В методе onCleared() реализуем отписку от него с помощью removeObserver. В свою же очередь LiveData будет уведомлять об изменении MutableLiveData, на которую и будет подписано наше представление.    

public class DetectorViewModel extends AndroidViewModel {
//для хранения вводимых данных, решил создать Repository, листинг его можно посмотреть на GitHub по линке в конце материала
  private IRepository repository;
  private LatLng point;
  private int radius;
  private LocationLiveData locationLiveData;
  private NetworkLiveData networkLiveData;
  private MediatorLiveData<Status> statusMediatorLiveData = new MediatorLiveData<>();
  private MutableLiveData<String> statusLiveData = new MutableLiveData<>();
  private String networkName;
  private float[] distance = new float[1];
  private Observer<Location> locationObserver = new Observer<Location>() {
      @Override
      public void onChanged(@Nullable Location location) {
          checkZone();
      }
  };
  private Observer<String> networkObserver = new Observer<String>() {
      @Override
      public void onChanged(@Nullable String s) {
          checkZone();
      }
  };
  private Observer<Status> mediatorStatusObserver = new Observer<Status>() {
      @Override
      public void onChanged(@Nullable Status status) {
          statusLiveData.setValue(status.toString());
      }
  };

  public DetectorViewModel(final Application application) {
      super(application);
      repository = Repository.getInstance(application.getApplicationContext());
      initVariables();
      locationLiveData = new LocationLiveData(application.getApplicationContext());
      networkLiveData = new NetworkLiveData(application.getApplicationContext());
      statusMediatorLiveData.addSource(locationLiveData, locationObserver);
      statusMediatorLiveData.addSource(networkLiveData, networkObserver);
      statusMediatorLiveData.observeForever(mediatorStatusObserver);
  }

//Для того чтобы зря не держать LocationService в работе, мы от него отписываемся если WiFi network подходит.
  private void updateLocationService() {
      if (isRequestedWiFi()) {
          statusMediatorLiveData.removeSource(locationLiveData);
      } else if (!isRequestedWiFi() && !locationLiveData.hasActiveObservers()) {
          statusMediatorLiveData.addSource(locationLiveData, locationObserver);
      }
  }

//считываем данные с репозитория
  private void initVariables() {
      point = repository.getPoint();
      if (point.latitude == 0 && point.longitude == 0)
          point = null;
      radius = repository.getRadius();
      networkName = repository.getNetworkName();
  }

//метод, который отвечает за проверку того находимся мы в нужной зоне или нет
  private void checkZone() {
      updateLocationService();
      if (isRequestedWiFi() || isInRadius()) {
          statusMediatorLiveData.setValue(Status.INSIDE);
      } else {
          statusMediatorLiveData.setValue(Status.OUTSIDE);
      }
  }

  public LiveData<String> getStatus() {
      return statusLiveData;
  }
// методы которые отвечают за запись данных в репозиторий
  public void savePoint(LatLng latLng) {
      repository.savePoint(latLng);
      point = latLng;
      checkZone();
  }

  public void saveRadius(int radius) {
      this.radius = radius;
      repository.saveRadius(radius);
      checkZone();
  }

  public void saveNetworkName(String networkName) {
      this.networkName = networkName;
      repository.saveNetworkName(networkName);
      checkZone();
  }

  public int getRadius() {
      return radius;
  }

  public LatLng getPoint() {
      return point;
  }

  public String getNetworkName() {
      return networkName;
  }

  public boolean isInRadius() {
      if (locationLiveData.getValue() != null && point != null) {
          Location.distanceBetween(locationLiveData.getValue().getLatitude(), locationLiveData.getValue().getLongitude(), point.latitude, point.longitude, distance);
          if (distance[0] <= radius)
              return true;
      }
      return false;
  }

  public boolean isRequestedWiFi() {
      if (networkLiveData.getValue() == null)
          return false;
      if (networkName.isEmpty())
          return false;
      String network = networkName.replace("\"", "").toLowerCase();
      String currentNetwork = networkLiveData.getValue().replace("\"", "").toLowerCase();
      return network.equals(currentNetwork);
  }

  @Override
  protected void onCleared() {
      super.onCleared();
      statusMediatorLiveData.removeSource(locationLiveData);
      statusMediatorLiveData.removeSource(networkLiveData);
      statusMediatorLiveData.removeObserver(mediatorStatusObserver);
  }
}

И наше представление:

public class MainActivity extends LifecycleActivity {
  private static final int PERMISSION_LOCATION_REQUEST = 0001;
  private static final int PLACE_PICKER_REQUEST = 1;
  private static final int GPS_ENABLE_REQUEST = 2;
  @BindView(R.id.status)
  TextView statusView;
  @BindView(R.id.radius)
  EditText radiusEditText;
  @BindView(R.id.point)
  EditText pointEditText;
  @BindView(R.id.network_name)
  EditText networkEditText;
  @BindView(R.id.warning_container)
  ViewGroup warningContainer;
  @BindView(R.id.main_content)
  ViewGroup contentContainer;
  @BindView(R.id.permission)
  Button permissionButton;
  @BindView(R.id.gps)
  Button gpsButton;
  private DetectorViewModel viewModel;
  private LatLng latLng;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      setContentView(R.layout.activity_main);
      ButterKnife.bind(this);
      checkPermission();
  }

  @Override
  public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
      super.onRequestPermissionsResult(requestCode, permissions, grantResults);
      if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
          init();
      } else {
          showWarningPage(Warning.PERMISSION);
      }
  }

  private void checkPermission() {
      if (PackageManager.PERMISSION_GRANTED == checkSelfPermission(
              Manifest.permission.ACCESS_FINE_LOCATION)) {
          init();
      } else {
          requestPermissions(new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, PERMISSION_LOCATION_REQUEST);
      }
  }


  private void init() {
      viewModel = ViewModelProviders.of(this).get(DetectorViewModel.class);
      if (Utils.isGpsEnabled(this)) {
          hideWarningPage();
          checkingPosition();
          initInput();
      } else {
          showWarningPage(Warning.GPS_DISABLED);
      }
  }

  private void initInput() {
      radiusEditText.setText(String.valueOf(viewModel.getRadius()));
      latLng = viewModel.getPoint();
      if (latLng == null) {
          pointEditText.setText(getString(R.string.chose_point));
      } else {
          pointEditText.setText(latLng.toString());
      }
      networkEditText.setText(viewModel.getNetworkName());
  }

  @OnClick(R.id.get_point)
  void getPointClick(View view) {
      PlacePicker.IntentBuilder builder = new PlacePicker.IntentBuilder();
      try {
          startActivityForResult(builder.build(MainActivity.this), PLACE_PICKER_REQUEST);
      } catch (GooglePlayServicesRepairableException e) {
          e.printStackTrace();
      } catch (GooglePlayServicesNotAvailableException e) {
          e.printStackTrace();
      }
  }

  @OnClick(R.id.save)
  void saveOnClick(View view) {
      if (!TextUtils.isEmpty(radiusEditText.getText())) {
          viewModel.saveRadius(Integer.parseInt(radiusEditText.getText().toString()));
      }
      viewModel.saveNetworkName(networkEditText.getText().toString());
  }

  @OnClick(R.id.permission)
  void permissionOnClick(View view) {
      checkPermission();
  }

  @OnClick(R.id.gps)
  void gpsOnClick(View view) {
      startActivityForResult(new Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS), GPS_ENABLE_REQUEST);
  }


  private void checkingPosition() {
      viewModel.getStatus().observe(this, new Observer<String>() {
          @Override
          public void onChanged(@Nullable String status) {
              updateUI(status);
          }
      });
  }

  private void updateUI(String status) {
      statusView.setText(status);
  }

  protected void onActivityResult(int requestCode, int resultCode, Intent data) {
      if (requestCode == PLACE_PICKER_REQUEST) {
          if (resultCode == RESULT_OK) {
              Place place = PlacePicker.getPlace(data, this);
              updatePlace(place.getLatLng());
          }
      }
      if (requestCode == GPS_ENABLE_REQUEST) {
          init();
      }
  }

  private void updatePlace(LatLng latLng) {
      viewModel.savePoint(latLng);
      pointEditText.setText(latLng.toString());
  }

  private void showWarningPage(Warning warning) {
      warningContainer.setVisibility(View.VISIBLE);
      contentContainer.setVisibility(View.INVISIBLE);
      switch (warning) {
          case PERMISSION:
              gpsButton.setVisibility(View.INVISIBLE);
              permissionButton.setVisibility(View.VISIBLE);
              break;
          case GPS_DISABLED:
              gpsButton.setVisibility(View.VISIBLE);
              permissionButton.setVisibility(View.INVISIBLE);
              break;
      }
  }

  private void hideWarningPage() {
      warningContainer.setVisibility(View.GONE);
      contentContainer.setVisibility(View.VISIBLE);
  }
}

В общих чертах мы подписываемся на MutableLiveData, с помощью меnода getStatus() из нашего ViewModel. А также работаем с ним для инициализации и сохранения наших данных.

Здесь также добавлено несколько проверок, таких как RuntimePermission и проверка на состояние GPS. Как можно заметить, код в Activity получился довольно обширный, в случае сложного UI, гугл рекомендует посмотреть в сторону создания презентера(но это может быть излишество).

В примере также использовались такие библиотеки как:

compile 'com.jakewharton:butterknife:8.6.0'
compile 'com.google.android.gms:play-services-maps:11.0.2'
compile 'com.google.android.gms:play-services-location:11.0.2'
compile 'com.google.android.gms:play-services-places:11.0.2'
annotationProcessor 'com.jakewharton:butterknife-compiler:8.6.0'

Полный листинг: here

Полезные ссылки: here и here

Android Architecture Components. Часть 1. Введение
Android Architecture Components. Часть 2. Lifecycle
Android Architecture Components. Часть 3. LiveData
Android Architecture Components. Часть 4. ViewModel
Tags:
Hubs:
+15
Comments 3
Comments Comments 3

Articles