April 28, 2020

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