Avance.Lab

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

技術紹介

第3回 顔認識デバイス Android編 〜 LiveDataとDataBindingを実装する 〜

tag: スマートデバイスIoT

前回に引き続き、MVVMの実装をしていきます。

ViewModelの実装では、UIの状態管理やロジックの分離を実施しました。

今回はLiveDataを使用して、Bluetoothデバイスとの接続状態の変化を監視しつつ適切なタイミングで自動更新する実装と、

DataBindingを使用してレイアウトファイルからその状態変化をUIへ更新できるように実装します。

前回:Android編 JetPackを使ってViewModelを実装する

1. 準備

LiveData、DataBinding を使用するために必要なライブラリを追加します。

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
    }

    // 追加
    dataBinding {
        enabled = true
    }

    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"
        implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"   // 追加

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

2. LiveDataの実装

前回作成した、ConnectViewModelへ下記状態を表すLiveDataを実装していきます。

  • Bluetooth 接続状態(sppConnectStatus)
  • 接続ボタンの Enabled 状態(connectButtonEnabled)
  • 切断ボタンの Enabled 状態(disconnectButtonEnabled)

また、Activity で継承していたISppClient(SPP通信のイベントリスナー)はViewModelに継承させます。

SppConnectStatusは通信状態を管理するために定義しています。

ConnectionViewModel.kt

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

    ...

    // liveData
    private val mSppConnectStatus = MutableLiveData<SppConnectStatus>()
    private val mConnectButtonEnabled = MutableLiveData<Boolean>()
    private val mDisconnectButtonEnabled = MutableLiveData<Boolean>()

    val sppConnectStatus: LiveData<SppConnectStatus> get() = mSppConnectStatus
    val connectButtonEnabled: LiveData<Boolean> get() = mConnectButtonEnabled
    val disconnectButtonEnabled: LiveData<Boolean> get() = mDisconnectButtonEnabled

    /**
     * Bluetoothデバイスとの接続状態
     */
    enum class SppConnectStatus {
        CONNECTED {
            override fun toast(context: Context) {
                Toast.makeText(context, "接続成功", Toast.LENGTH_SHORT).show()
            }
        },
        CONNECT_ERROR {
            override fun toast(context: Context) {
                Toast.makeText(context, "接続失敗", Toast.LENGTH_SHORT).show()
            }
        },
        DISCONNECTED {
            override fun toast(context: Context) {
                Toast.makeText(context, "接続切断", Toast.LENGTH_SHORT).show()
            }
        },
        NOT_CONNECTION {
            override fun toast(context: Context) {}
        };

        abstract fun toast(context: Context)
    }

    init {
        deviceAdapter.setDropDownViewResource(R.layout.spinner_dropdown_item)
        mSppConnectStatus.value = SppConnectStatus.NOT_CONNECTION
        mConnectButtonEnabled.value = false
        mDisconnectButtonEnabled.value = false

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

    ...

    fun updateButtonState() {
        if (MyApplication.instance.sppClient.isConnected()) {
            mConnectButtonEnabled.postValue(false)
            mDisconnectButtonEnabled.postValue(true)
        } else {
            mConnectButtonEnabled.postValue(btDevice != null)
            mDisconnectButtonEnabled.postValue(false)
        }
    }

    // ISppClient method
    override fun onConnected(result: Boolean) {
        viewModelScope.launch {
            if (result) {
                // 接続成功
                mSppConnectStatus.postValue(SppConnectStatus.CONNECTED)
            } else {
                // 接続失敗
                mSppConnectStatus.postValue(SppConnectStatus.CONNECT_ERROR)
            }

            updateButtonState()
        }
    }

    // ISppClient method
    override fun onDisconnected() {
        viewModelScope.launch {
            // 接続切断
            mSppConnectStatus.postValue(SppConnectStatus.DISCONNECTED)
            updateButtonState()
            // 300msec の遅延を挿入後、ステータスを戻す
            delay(300)
            mSppConnectStatus.postValue(SppConnectStatus.NOT_CONNECTION)
        }
    }
}

2.1 初期化

まずは、LiveDataオブジェクトの宣言と初期化です。

外部から参照するときは、イミュータブル型にしていますが、このあたりはお好みです。

値の変更は、valueに代入するか、postValue()を使用することになります。

どちらも意味合いは同じですが、valueはメインスレッドから実行する必要があり、

postValue()はバックグラウンドから実行してもメインスレッドへ最新値がディスパッチされるようになっています。

2.2 ボタンの状態管理

既存コードではボタンの Enabled を直接操作していましたが、後述するDataBindingで UI操作をActivityから分離するようにします。

postValue()を使用してボタンの最新状態をViewへ反映します。

    fun updateButtonState() {
        if (MyApplication.instance.sppClient.isConnected()) {
            mConnectButtonEnabled.postValue(false)
            mDisconnectButtonEnabled.postValue(true)
        } else {
            mConnectButtonEnabled.postValue(btDevice != null)
            mDisconnectButtonEnabled.postValue(false)
        }
    }

2.3 Bluetooth通信の状態管理

接続成功、接続失敗、接続切断の通信状態を監視し、状態更新に合わせてトーストを表示できるようにします。

    // ISppClient method
    override fun onConnected(result: Boolean) {
        viewModelScope.launch {
            if (result) {
                // 接続成功
                mSppConnectStatus.postValue(SppConnectStatus.CONNECTED)
            } else {
                // 接続失敗
                mSppConnectStatus.postValue(SppConnectStatus.CONNECT_ERROR)
            }

            updateButtonState()
        }
    }

    // ISppClient method
    override fun onDisconnected() {
        viewModelScope.launch {
            // 接続切断
            mSppConnectStatus.postValue(SppConnectStatus.DISCONNECTED)
            updateButtonState()
            // 300msec の遅延を挿入後、ステータスを戻す
            delay(300)
            mSppConnectStatus.postValue(SppConnectStatus.NOT_CONNECTION)
        }
    }

LiveDataの値変更はobserve()で監視します。

ConnectActivity.kt

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

        ...

        // liveData observer
        viewModel.sppConnectStatus.observe(this, Observer { connectStatus ->
            connectStatus.toast(this)
        })
    }

3. DataBindingの実装

DataBindingはレイアウトファイルから指定したクラスの変数やメソッドを参照することが出来ます。これにより、ActivityやFragmentからUI操作を分離できます。

今回は、ボタンの 有効/無効 と クリックイベント を紐付けていきます。

3.1 レイアウトファイルの修正

レイアウトファイルからViewModelを参照するにはルート階層を<layout> … </layout>で囲む必要があります。

ルート階層を右クリックして、[Show Context Actions] → [Convert to data binding layout] をクリックすると自動変換してくれます。便利です!

activity_connect.xml

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <variable
            name="viewModel"
            type="jp.co.avancesys.sample.demo001.view.screen.connect.ConnectViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/app_background"
        tools:context=".view.screen.connect.ConnectActivity">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            android:padding="8dp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent">

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal">

                <Spinner
                    android:id="@+id/spinnerDevices"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_gravity="center_vertical"
                    android:layout_margin="8dp"
                    android:layout_weight="1"
                    android:backgroundTint="#FFFFFF" />

                <Button
                    android:id="@+id/buttonConnect"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="connect"
                    android:enabled="@{viewModel.connectButtonEnabled}"
                    android:onClick="@{(v) -> viewModel.connectBtDevice()}"/>

                <Button
                    android:id="@+id/buttonDisconnect"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="disconnect"
                    android:enabled="@{viewModel.disconnectButtonEnabled}"
                    android:onClick="@{(v) -> viewModel.disconnectBtDevice()}"/>

            </LinearLayout>

        </LinearLayout>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

<data> … </data>で囲まれた箇所が参照するViewModelとなり、呼び出しは@{}で囲みます。

ここでは、先程LiveDataで定義した接続ボタンと切断ボタンの状態変数をandroid:enabled=へ、ボタンをタップしたときの内部処理をandroid:onClick=へバインドしています。

3.2 Activityの修正

それでは、仕上げにActivityを修正します。

まず、DataBindingUtil.setContentView<T>()でDataBindingのオブジェクトを取得します。Tに指定しているActivityConnectBindingは自動生成です。

次に、binding.viewModelbinding.lifecycleOwnerを紐付けたら完了です!

どうでしょう。Activity内の処理がほとんどなくなりスッキリした気がします。

ConnectActivity.kt

class ConnectActivity : BaseActivity() {

    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)

        val binding = DataBindingUtil.setContentView<ActivityConnectBinding>(this, R.layout.activity_connect)
        binding.viewModel = viewModel
        binding.lifecycleOwner = this

        supportActionBar?.setDisplayHomeAsUpEnabled(true)

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

        // liveData observer
        viewModel.sppConnectStatus.observe(this, Observer { connectStatus ->
            connectStatus.toast(this)
        })
    }

    override fun onResume() {
        super.onResume()

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

        viewModel.updateButtonState()
    }
}

最後に

Android JetPackを使用して簡単なMVVM設計に修正してみました。

UIとロジックの責任分担ができ、ソースコードの保守性も上がりました。

さらには、これまで開発者が気にしなければならなかったライフサイクル絡みのUI実装がJetPackライブラリで吸収され、効率よくアプリ開発が可能になったと思います。

この他にもNavigation、Room等の開発スピードを上げるライブラリを弊社では積極的に取り入れています。

参考

次回

Bluetooth SPP通信の実装を紹介していきます。

N
N

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

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