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