Avance.Lab

クラウドから組み込みまで、
弊社のソフトウェア開発ノウハウを紹介

技術紹介

第2回 顔認識デバイス Android編 〜 JetPackを使ってViewModelを実装する 〜

tag: スマートデバイスIoT

最近のAndroidアプリはJetPackライブラリが浸透してきたこともあり、MVVMを使うことは当たり前のようになっていますよね。

そこで、今回は、アプリのBluetooth接続画面をViewModel + Coroutine + Lifecycleを使って、MVVM設計にしてみようと思います!

MVVMとは? – Wikipedia参照

既存ソースコード

下記はBluetooth通信のイベントハンドラーやボタン状態変化も含めた既存コードです。ここから処理を分離していきます。

class ConnectActivity : BaseActivity(), ISppClient {

    private lateinit var deviceAdapter: ArrayAdapter<String>
    private var btDeviceSet: Set<BluetoothDevice>? = null
    private var btDevice: BluetoothDevice? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_connect)

        supportActionBar?.setDisplayHomeAsUpEnabled(true)

        MyApplication.instance.sppClient.addListener(this)

        deviceAdapter = ArrayAdapter(this,
            R.layout.spinner_item
        )
        deviceAdapter.setDropDownViewResource(R.layout.spinner_dropdown_item)
        spinnerDevices.adapter = deviceAdapter

        buttonConnect.setOnClickListener {
            MyApplication.instance.sppClient.connect(btDevice)
        }

        buttonDisconnect.setOnClickListener {
            MyApplication.instance.sppClient.disconnect()
        }
    }

    override fun onResume() {
        super.onResume()

        // 接続中の場合はそのデバイスを初期選択項目にする
        if (MyApplication.instance.sppClient.isConnected()) {
            btDevice = MyApplication.instance.sppClient.mBTDevice
        }

        deviceAdapter.clear()

        val btAdapter = BluetoothAdapter.getDefaultAdapter()
        btDeviceSet = btAdapter.bondedDevices
        if (btDeviceSet != null) {

            var selectIndex = 0
            var index = 0

            for (device in btDeviceSet!!) {
                deviceAdapter.add(device.name)

                if (device.name.equals(btDevice?.name)) {
                    selectIndex = index
                }
                index++
            }

            if (btDevice == null && btDeviceSet!!.size > 0) {
                btDevice = btDeviceSet!!.first()
                selectIndex = 0
            }

            if (btDevice != null && spinnerDevices.adapter.count > selectIndex) {
                spinnerDevices.setSelection(selectIndex, false)
            }
        }

        spinnerDevices.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
            override fun onItemSelected(p0: AdapterView<*>?, p1: View?, p2: Int, p3: Long) {
                // デバイス変更時に切断する
                MyApplication.instance.sppClient.disconnect()

                if (btDeviceSet != null) {
                    val selectedName = deviceAdapter.getItem(p2)
                    for (device in btDeviceSet!!) {
                        if (device.name.equals(selectedName)) {
                            btDevice = device
                            break
                        }
                    }
                }
            }

            override fun onNothingSelected(p0: AdapterView<*>?) {
            }
        }

        updateButtonState()
    }

    fun updateButtonState() {
        if (MyApplication.instance.sppClient.isConnected()) {
            buttonConnect.isEnabled = false
            buttonDisconnect.isEnabled = true
        } else {
            buttonDisconnect.isEnabled = false
            buttonConnect.isEnabled = (btDevice != null)
        }
    }

    override fun onConnected(result: Boolean) {
        runOnUiThread {
            if (isRunning) {
                if (result) {
                    Toast.makeText(this, "接続成功", Toast.LENGTH_SHORT).show()
                } else {
                    Toast.makeText(this, "接続失敗", Toast.LENGTH_SHORT).show()
                }

                updateButtonState()
            }
        }
    }

    override fun onDisconnected() {
        runOnUiThread {
            if (isRunning) {
                Toast.makeText(this, "接続切断", Toast.LENGTH_SHORT).show()
                updateButtonState()
            }
        }
    }
}

1. 準備

Coroutine, Lifecycle を使用するために必要なライブラリを追加します。

buid.gradle(app)

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt' // 追加

android {
    ...

    // 追加
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }

    // 追加
    kotlinOptions {
        jvmTarget = '1.8'
    }

    dependencies {
        ...
        // lifecycle 追加
        implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
        implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
        implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"

        // coroutine 追加
        implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
        implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
    }
}

2. ViewModelの実装

Bluetooth接続画面ではペアリングしたデバイスのスピナー表示と接続、切断ボタンのイベントが存在するため、これらの処理をViewModelへ記述していきます。

ConnectViewModel.kt

class ConnectViewModel(val app: Application) : AndroidViewModel(app) {

    private var mBtDeviceSet: Set<BluetoothDevice>? = null      // ペアリング済みのデバイス一覧
    private var mBtDevice: BluetoothDevice? = null              // 接続中のデバイス

    val deviceAdapter: ArrayAdapter<String> = ArrayAdapter(app, R.layout.spinner_item)
    val btDeviceSet get() = mBtDeviceSet
    val btDevice get() = mBtDevice

    init {
        deviceAdapter.setDropDownViewResource(R.layout.spinner_dropdown_item)
    }

    fun connectBtDevice() = MyApplication.instance.sppClient.connect(mBtDevice)

    fun disconnectBtDevice() = MyApplication.instance.sppClient.disconnect()

    val spinnerOnItemSelectedListener = object : SpinnerOnItemSelectedListener {
        override var isSelectedFirst: Boolean = false

        override fun onItemSelectedSpinner(
            parent: AdapterView<*>?,
            view: View?,
            position: Int,
            id: Long
        ) {
            // デバイス変更時に切断する
            MyApplication.instance.sppClient.disconnect()

            if (mBtDeviceSet != null) {
                val selectedName = deviceAdapter.getItem(position)
                for (device in mBtDeviceSet!!) {
                    if (device.name == selectedName) {
                        mBtDevice = device
                        break
                    }
                }
            }
        }

        override fun onNothingSelected(parent: AdapterView<*>?) {
        }
    }

    fun updateSpinner(callback: (selectIndex: Int) -> Unit) =
        viewModelScope.launch {
            var selectIndex = 0

            // 接続中の場合はそのデバイスを初期選択項目にする
            if (MyApplication.instance.sppClient.isConnected()) {
                mBtDevice = MyApplication.instance.sppClient.mBTDevice
            }

            deviceAdapter.clear()

            val btAdapter = BluetoothAdapter.getDefaultAdapter()
            // ペアリング済みのデバイスを取得
            mBtDeviceSet = btAdapter.bondedDevices

            mBtDeviceSet?.let { btSet ->
                for ((index, device) in btSet.withIndex()) {
                    deviceAdapter.add(device.name)

                    if (device.name == mBtDevice?.name) {
                        selectIndex = index
                    }
                }

                if (btSet.isNotEmpty()) {
                    mBtDevice = btSet.first()
                    selectIndex = 0
                }
            }
            callback(selectIndex)
        }
}

解説します。

ViewModelを作成する際は、ViewModelもしくはAndroidViewModelを継承します。

今回はContextをViewModelで使用するため、AndroidViewModelを継承しています。Contextを使用しない場合はViewModelで良いです。

Activityでこれまで管理していた以下の要素は全てViewModelへ移行しています。

  • スピナーアイテム(deviceAdapter)
  • Bluetoothデバイスオブジェクト(btDeviceSet, btDevice)
  • 接続ボタンの内部処理(connectBtDevice())
  • 切断ボタンの内部処理(disconnectBtDevice())
  • スピナーアイテムをタップしたときの内部処理(spinnerOnItemSelectedListener)

これはViewに関する処理はActivity、Viewの状態管理をViewModel、と役割を分けているためです。

updateSpinner()は端末設定からペアリングデバイスを追加したあと、アプリへ遷移したことを想定してonResume()から呼び出しています。

ここで下記viewModelScopeが登場しますが、これはViewModel用に設計されたコルーチンです。ViewModelが破棄されるとき、このコルーチン内で実行されている処理は全て自動的にキャンセルされる事になっているため、AsyncTask等で管理する必要が無くなるということになりますね。

非同期処理からUI更新をするようなときは積極的に使っていきます。

viewModelScope.launch {
    ...
}

少し話がそれてしまうのですが、スピナーへAdapterView.OnItemSelectedListenerをアタッチした際にonItemSelected()が呼ばれてしまうので、これを回避するための実装をSpinnerOnItemSelectedListenerにしています。

3. Activityの実装

Activityの実装は簡単です!

ViewModelを生成し、スピナーやボタンと関連付けるだけです。

class ConnectActivity : BaseActivity(), ISppClient {

    private val viewModel: ConnectViewModel by lazy {
        val factory = ViewModelProvider.AndroidViewModelFactory.getInstance(application)
        ViewModelProvider(this, factory).get(ConnectViewModel::class.java)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_connect)

        supportActionBar?.setDisplayHomeAsUpEnabled(true)

        // SPP通信のイベントリスナー
        MyApplication.instance.sppClient.addListener(this)

        // スピナー
        spinnerDevices.adapter = viewModel.deviceAdapter
        spinnerDevices.onItemSelectedListener = viewModel.spinnerOnItemSelectedListener

        // 接続ボタン
        buttonConnect.setOnClickListener {
            viewModel.connectBtDevice()
        }

        // 切断ボタン
        buttonDisconnect.setOnClickListener {
            viewModel.disconnectBtDevice()
        }
    }

    override fun onResume() {
        super.onResume()

        // スピナーの内容を更新
        viewModel.updateSpinner { selectIndex ->
            spinnerDevices.setSelection(selectIndex, false)
        }

        updateButtonState()
    }

    ...
}

参考

次回

まだ少し不十分なので、ボタンの表示状態を ViewModel + LiveData + DataBinding を使用することでより、MVVM設計に近づける実装していきます。

N
N

スマートフォンアプリやIoTに関連した開発を主にしています。

「モダン」と言われるものが好物です。