Android Architecture Components. Часть 4. ViewModel
Компонент ViewModel предназначен для хранения и управления данными, связанными с представлением, а заодно и для избавления нас от проблемы, связанной с пересозданием Activity во время таких операций, как переворот экрана и т.д. Не стоит его воспринимать как замену onSaveInstanceState, поскольку, после того как система уничтожит нашу активити, к примеру, когда мы перейдем в другое приложение, ViewModel будет также уничтожена и не сохранит свое состояние. В целом же, компонент ViewModel можно охарактеризовать как синглтон с коллекцией экземпляров классов ViewModel, который гарантирует, что не будет уничтожен, пока есть активный экземпляр нашей активити, и освободит ресурсы после ухода с нее (все немного сложнее, но выглядит как-то так). Стоит также отметить, что мы можем привязать любое количество ViewModel к нашей Activity/Fragment.
Примечание: данный цикл статей был написан летом 2017 года, поэтому некоторая информация может быть немного устаревшей, но общая концепция архитектурных компонентов с тех пор не изменилась.
Компонент состоит из таких классов: ViewModel, AndroidViewModel, ViewModelProvider, ViewModelStore, ViewModelStores. Разработчик будет работать только с ViewModel, AndroidViewModel и для получения инстанса с ViewModelProvider, но для лучшего понимания компонента мы поверхностно рассмотрим все классы.
Класс ViewModel сам по себе представляет абстрактный класс без абстрактных методов и с одним protected методом onCleared()
. Для реализации собственного ViewModel нам всего лишь необходимо унаследовать свой класс от ViewModel с конструктором без параметров, и это все. Если же нам нужно очистить ресурсы, то необходимо переопределить метод onCleared()
, который будет вызван, когда ViewModel долго недоступна и должна быть уничтожена. Как пример, можно вспомнить предыдущую статью про LiveData, а конкретно о методе observeForever(Observer)
, который требует явной отписки, и как раз в методе onCleared()
уместно ее реализовать. Стоит еще добавить, что для избежания утечки памяти не нужно ссылаться напрямую на View/Activity/Context из ViewModel. В целом, ViewModel должна быть абсолютно изолирована от представления данных. В таком случае появляется вопрос: А каким же образом нам уведомить представление (Activity/Fragment) об изменениях в наших данных? В этом случае на помощь нам приходит LiveData, все изменяемые данные мы должны хранить с помощью LiveData, если же нам необходимо, к примеру, показать и скрыть ProgressBar, мы можем создать MutableLiveData и хранить логику показать/скрыть в компоненте ViewModel. В общем, это будет выглядеть так:
class MyViewModel : ViewModel() { private val showProgress = MutableLiveData<Boolean>() //new thread fun doSomeThing() { showProgress.postValue(true) ... showProgress.postValue(false) } fun getProgressState(): MutableLiveData<Boolean> { return showProgress } }
Для получения ссылки на наш экземпляр ViewModel мы должны воспользоваться ViewModelProvider:
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val viewModel = ViewModelProvider(this).get(MyViewModel::class.java) viewModel.getProgressState().observe(this) { show -> if (show) { showProgress() } else { hideProgress() } } viewModel.doSomeThing() }
Класс AndroidViewModel является расширением ViewModel с единственным отличием — в конструкторе должен быть один параметр Application. Является довольно полезным расширением в случаях, когда нам нужно использовать Location Service или другой компонент, требующий Application Context. В работе с ним единственное отличие – это то, что мы наследуем наш ViewModel от ApplicationViewModel. В Activity/Fragment инициализируем его точно также, как и обычный ViewModel.
Класс ViewModelProvider – это, собственно говоря, класс, который возвращает наш инстанс ViewModel. Не будем особо углубляться здесь, в общих чертах он являет роль посредника с ViewModelStore, который хранит и поднимает наш интанс ViewModel и возвращает его с помощью метода get
, который имеет две сигнатуры get(Class)
и get(key: String, modelClass: Class)
. Смысл заключается в том, что мы можем привязать несколько ViewModel к нашему Activity/Fragment даже одного типа. Метод get
возвращает их по 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 сети:
class LocationLiveData( context: Context ) : LiveData<Location>(), GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener, LocationListener { companion object { private const val UPDATE_INTERVAL = 1000 } private val googleApiClient = GoogleApiClient.Builder(context, this, this) .addApi(LocationServices.API) .build() } override fun onActive() { googleApiClient.connect() } override fun onInactive() { if (googleApiClient.isConnected()) { LocationServices.FusedLocationApi .removeLocationUpdates(googleApiClient, this) } googleApiClient.disconnect() } override fun onConnected(connectionHint: Bundle?) { val locationRequest = LocationRequest() .setInterval(UPDATEINTERVAL) .setPriority(LocationRequest.PRIORITY_HHIGHCURACY) LocationServices.FusedLocationApi .requestLocationUpdates(googleApiClient, locationRequest, this) } override fun onLocationChanged(location: Location) { setValue(location) } override fun onConnectionSuspended(cause: Int) { setValue(null) } override fun onConnectionFailed(connectionResult: ConnectionResult) { setValue(null) } }
class NetworkLiveData( private val context: Context ) : LiveData<String> { private var broadcastReceiver: BroadcastReceiver? = null private fun prepareReceiver(context: Context) { val filter = IntentFilter() filter.addAction("android.net.wifi.supplicant.CONNECTIONCHANGE") filter.addAction("android.net.wifi.STATECHANGE") broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val wifiMgr = context.getSystemService(Context.WIFI_SERVICE) as WifiManager val wifiInfo = wifiMgr.getConnectionInfo() val name = wifiInfo.getSSID() if (name.isEmpty()) { setValue(null) } else { setValue(name) } } } context.registerReceiver(broadcastReceiver, filter) } override fun onActive() { super.onActive() prepareReceiver(context) } override fun onInactive() { super.onInactive() context.unregisterReceiver(broadcastReceiver) broadcastReceiver = null } }
Теперь перейдем к ViewModel. Поскольку у нас есть условие, которое зависит от полученных данных с двух LiveData, нам идеально подойдет MediatorLiveData как холдер самого значения. Но поскольку перезапускать сервисы нам невыгодно, подпишемся к MediatorLiveData без привязки к жизненному циклу с помощью observeForever. В методе onCleared()
реализуем отписку от него с помощью removeObserver. В свою же очередь LiveData будет уведомлять об изменении MutableLiveData, на которую и будет подписано наше представление.
class DetectorViewModel( application: Application ) : AndroidViewModel(application) { //для хранения вводимых данных, решил создать Repository, листинг его можно посмотреть на GitHub по линке в конце материала private val repository: IRepository private var point: LatLng? = null private var radius = 0 private val locationLiveData: LocationLiveData private val networkLiveData: NetworkLiveData private val statusMediatorLiveData: MediatorLiveData<Status> = MediatorLiveData() private val statusLiveData: MutableLiveData<String> = MutableLiveData() private var networkName: String = "" private val distance = FloatArray(1) private val locationObserver = object : Observer<Location> { override fun onChanged(location: Location) { checkZone() } } private val networkObserver = object : Observer<String> { override fun onChanged(s: String) { checkZone() } } private val mediatorStatusObserver = object : Observer<Status> { override fun onChanged(status: Status) { statusLiveData.setValue(status.toString()) } } val status: LiveData<String> get() = statusLiveData init { repository = Repository.getInstance(application.getApplicationContext()) point = repository.getPoint() if (point.latitude == 0 && point.longitude == 0) point = null radius = repository.getRadius() networkName = repository.getNetworkName() locationLiveData = LocationLiveData(application.getApplicationContext()) networkLiveData = NetworkLiveData(application.getApplicationContext()) statusMediatorLiveData.addSource(locationLiveData, locationObserver) statusMediatorLiveData.addSource(networkLiveData, networkObserver) statusMediatorLiveData.observeForever(mediatorStatusObserver) } //Для того чтобы зря не держать LocationService в работе, мы от него отписываемся, если WiFi network подходит private fun updateLocationService() { if (isRequestedWiFi()) { statusMediatorLiveData.removeSource(locationLiveData) } else if (!isRequestedWiFi() && !locationLiveData.hasActiveObservers()) { statusMediatorLiveData.addSource(locationLiveData, locationObserver) } } //метод, который отвечает за проверку того, находимся мы в нужной зоне или нет private fun checkZone() { updateLocationService() if (isRequestedWiFi() || isInRadius()) { statusMediatorLiveData.setValue(Status.INSIDE) } else { statusMediatorLiveData.setValue(Status.OUTSIDE) } } // методы, которые отвечают за запись данных в репозиторий fun savePoint(latLng: LatLng?) { repository.savePoint(latLng) point = latLng checkZone() } fun saveRadius(radius: Int) { this.radius = radius repository.saveRadius(radius) checkZone() } fun saveNetworkName(networkName: String) { this.networkName = networkName repository.saveNetworkName(networkName) checkZone() } fun getPoint() = point fun getRadius() = radius fun getNetworkName() = networkName fun isInRadius(): Boolean { 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 } fun isRequestedWiFi(): Boolean { if (networkLiveData.getValue() == null) return false if (networkName.isEmpty()) return false val network = networkName.replace("\"", "").toLowerCase() val currentNetwork: String = networkLiveData.getValue().replace("\"", "").toLowerCase() return network == currentNetwork } override fun onCleared() { super.onCleared() statusMediatorLiveData.removeSource(locationLiveData) statusMediatorLiveData.removeSource(networkLiveData) statusMediatorLiveData.removeObserver(mediatorStatusObserver) } }
И наше представление:
class MainActivity : LifecycleActivity() { companion object { private const val PERMISSION_LOCATION_REQUEST = 1 private const val PLACE_PICKER_REQUEST = 1 private const val GPS_ENABLE_REQUEST = 2 } @BindView(R.id.status) var statusView: TextView? = null @BindView(R.id.radius) var radiusEditText: EditText? = null @BindView(R.id.point) var pointEditText: EditText? = null @BindView(R.id.network_name) var networkEditText: EditText? = null @BindView(R.id.warning_container) var warningContainer: ViewGroup? = null @BindView(R.id.main_content) var contentContainer: ViewGroup? = null @BindView(R.id.permission) var permissionButton: Button? = null @BindView(R.id.gps) var gpsButton: Button? = null private var viewModel: DetectorViewModel? = null private var latLng: LatLng? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) ButterKnife.bind(this) checkPermission() } override fun onRequestPermissionsResult( requestCode: Int, permissions: Array<String?>, grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { init() } else { showWarningPage(Warning.PERMISSION) } } private fun checkPermission() { if (PackageManager.PERMISSION_GRANTED == checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)) { init() } else { requestPermissions( arrayOf<String>(Manifest.permission.ACCESS_FINE_LOCATION), PERMISSION_LOCATION_REQUEST ) } } private fun init() { viewModel = ViewModelProviders.of(this).get(DetectorViewModel::class.java) if (Utils.isGpsEnabled(this)) { hideWarningPage() checkingPosition() initInput() } else { showWarningPage(Warning.GPS_DISABLED) } } private fun initInput() { radiusEditText.setText(java.lang.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) fun getPointClick(view: View?) { val builder: PlacePicker.IntentBuilder = IntentBuilder() try { startActivityForResult(builder.build(this@MainActivity), PLACE_PICKER_REQUEST) } catch (e: GooglePlayServicesRepairableException) { e.printStackTrace() } catch (e: GooglePlayServicesNotAvailableException) { e.printStackTrace() } } @OnClick(R.id.save) fun saveOnClick(view: View?) { if (!TextUtils.isEmpty(radiusEditText.getText())) { viewModel.saveRadius(radiusEditText.getText().toString().toInt()) } viewModel.saveNetworkName(networkEditText.getText().toString()) } @OnClick(R.id.permission) fun permissionOnClick(view: View?) { checkPermission() } @OnClick(R.id.gps) fun gpsOnClick(view: View?) { startActivityForResult( Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS), GPS_ENABLE_REQUEST ) } private fun checkingPosition() { viewModel.getStatus().observe(this, object : Observer<String?> { fun onChanged(@Nullable status: String) { updateUI(status) } }) } private fun updateUI(status: String) { statusView.setText(status) } protected fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == PLACE_PICKER_REQUEST) { if (resultCode == RESULT_OK) { val place: Place = PlacePicker.getPlace(data, this) updatePlace(place.getLatLng()) } } if (requestCode == GPS_ENABLE_REQUEST) { init() } } private fun updatePlace(latLng: LatLng) { viewModel.savePoint(latLng) pointEditText.setText(latLng.toString()) } private fun showWarningPage(warning: Warning) { warningContainer.setVisibility(View.VISIBLE) contentContainer.setVisibility(View.INVISIBLE) when (warning) { PERMISSION -> { gpsButton.setVisibility(View.INVISIBLE) permissionButton.setVisibility(View.VISIBLE) } GPS_DISABLED -> { gpsButton.setVisibility(View.VISIBLE) permissionButton.setVisibility(View.INVISIBLE) } } } private fun hideWarningPage() { warningContainer.setVisibility(View.GONE) contentContainer.setVisibility(View.VISIBLE) } }
В общих чертах: мы подписываемся на MutableLiveData с помощью метода 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'
Полный листинг тут. Полезные ссылки: раз и два.
Android Architecture Components. Часть 1. Введение
Android Architecture Components. Часть 2. Lifecycle
Android Architecture Components. Часть 3. LiveData
Источник: Android Architecture Components. Часть 4. ViewModel