近接センサーチェックアプリ開発を通して学ぶ、Android推奨アーキテクチャとKotlin Flow(と、ほんの少しのLLM)
はじめに
こんにちは、Fairy Devicesの吉川(@emergent)です。
当社に入社してから約6年半、これまでプロダクトのコードはRustで書くことが多かったのですが、今年は自社製品のウェアラブルデバイスTHINKLETのアプリを作っていきたいと思い、Androidアプリ開発にチャレンジしています。
Androidアプリ開発はまったくのはじめてというわけではありませんが、自身の実装経験はわずかしかなく「なにもかも MainActivity
に実装してしまう」という素人アプリ開発者のまま過ごしてきました。それから時を経て、Webアプリケーション開発をそこそこやってみたり本を書いたり[1]したことで、アーキテクチャを考慮することによる責務の分離の重要性を身をもって経験したのでした。
前置きが長くなりましたが、本記事は、Androidアプリ開発に不慣れな筆者が多少ズルをしながら(≒効率的なやり方で)、新しめのフレームワークや設計をキャッチアップしていこうという記事です。
作りたいアプリの概要
現在流通しているAndroidデバイスの多くには、近接センサーというものが備わっています。当社のTHINKLETでは、装着時に首の裏側に来る場所に近接センサーがあります。
一般的なスマートフォンの場合は、通話時に顔と接触している・裏返しに置かれていることを検知して画面を消灯するといった用途などに使われていますが、THINKLETの場合は「首に装着しているかどうか」の判定に利用できます。
これを利用するサンプルとして「近接センサーが反応したら音で知らせる」というシンプルなアプリを、表題のAndroid推奨アーキテクチャで実装します。
作ったものは以下のリポジトリにて公開していますので、記事といっしょに参照していただければと思います。
効率的な開発のための準備
Androidアプリの開発環境を整えたら、追加で以下の2点を用意します。
- 当社がOSSとして公開しているAndroidアプリサンプルコード(参照)
- ChatGPTやGemini、Claude 3など最近のLLMを利用できる環境
効率的な開発とはすなわち、「有識者の前例」と「AIによるコード生成」です。前者によってアプリ内のアーキテクチャやデータの流れを決め、ほどよく責務を分離した上で、詳細なコードはAIに生成してもらいます。
何も考えずに作るとできてしまうもの
今回の趣旨のようにアーキテクチャにこだわらないままAIにコード生成をしてもらうと、以下のような「Activityに全部実装するアプリ」になってしまいます。今回のアプリ程度のシンプルなアプリでは問題ないですが、より機能や状態が多いアプリを想定すると、Activityの肥大化のリスクが多いにあります。適切な責務分離は、アプリケーションの種類にかかわらず重要なので、次節ではAndroidアプリにおける推奨アーキテクチャに触れていきます。
MainActivityにすべてが詰め込まれたコード
package com.example.proximitysound
// import文は省略
class MainActivity : ComponentActivity(), SensorEventListener {
private lateinit var sensorManager: SensorManager
private var proximitySensor: Sensor? = null
private var mediaPlayerNear: MediaPlayer? = null
private var mediaPlayerFar: MediaPlayer? = null
private var isNear by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// センサーマネージャーを取得
sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
proximitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY)
// 音の準備(raw フォルダに `sound_near.mp3` と `sound_far.mp3` を配置)
mediaPlayerNear = MediaPlayer.create(this, R.raw.sound_near)
mediaPlayerFar = MediaPlayer.create(this, R.raw.sound_far)
setContent {
ProximitySoundTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
ProximityScreen(isNear)
}
}
}
}
// onResume, onPause, onDestroyは省略
// センサーの値が変化したときに呼ばれるコールバック
override fun onSensorChanged(event: SensorEvent?) {
event?.let {
val newIsNear = it.values[0] < proximitySensor!!.maximumRange
if (newIsNear != isNear) {
isNear = newIsNear
if (isNear) {
mediaPlayerNear?.start()
} else {
mediaPlayerFar?.start()
}
}
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
@Composable
fun ProximityScreen(isNear: Boolean) {
// 画面表示のJetpack Compose実装。省略。
}
}
Androidアプリの推奨アーキテクチャに合わせた構成
Androidアプリのアーキテクチャガイドというページには、以下の3つのレイヤーからなる構成で推奨アーキテクチャが説明されています。
- UIレイヤー:UIとユーザーインタラクションを処理します
- データレイヤー:近接センサーへのアクセスとデータ取得、効果音の再生を担います
- ドメインレイヤー(Optional):今回のアプリではビジネスロジックがシンプルであるため実装しません
ドメインレイヤーはさておき、UIレイヤー/データレイヤーをどのように実装するか、当社の公開リポジトリのサンプルアプリのコードで見てみます。
- Lifelog(長時間録画・録音アプリ) - UIレイヤーとデータレイヤーをモジュールとして分割
- SquidRun(ストリーミング配信アプリ) - UIレイヤーにおけるViewとViewModelの構成
これらを参考に、プロジェクト内のモジュール構成は以下のようにしました。tree
コマンドの出力で必要な部分のみ抜粋&一部編集しています。
プロジェクトのツリー構成(Kotlin部分のみ抜粋)
.
├── app
│ └── src
│ ├── main
│ │ ├── java
│ │ │ └── ai/fd/thinklet/app/proximitychecker
│ │ │ ├── App.kt
│ │ │ ├── MainActivity.kt
│ │ │ ├── MainViewModel.kt
│ │ │ ├── ProximityScreen.kt
│ │ │ └── ui/
│ │ └── res/
│ └── test/
└── data
└── src
├── main
│ ├── java
│ │ └── ai/fd/thinklet/library/proximitychecker/data
│ │ ├── RepositoryProvider.kt
│ │ ├── proximity
│ │ │ ├── ProximityRepository.kt
│ │ │ └── impl
│ │ │ └── DefaultProximityRepositoryImpl.kt
│ │ └── soundeffect
│ │ ├── SoundEffectRepository.kt
│ │ └── impl
│ │ ├── SoundEffectLoader.kt
│ │ └── SoundEffectRepositoryImpl.kt
│ └── res/ # res/raw配下に効果音ファイルを配置
└── test/
Android Studioでは、プロジェクトの新規作成ウィザードにしたがうと app
モジュールは必ず生成されます。ウィザードでベースを作成したあと、追加で data
モジュールを作成します。app
と data
の役割分担は以下の通りです。
-
app
モジュール(=UIレイヤー):画面を構成するProximityScreen
と画面のためのデータを取りまとめるMainViewModel
を置く -
data
モジュール(=データレイヤー):実際に近接センサーの値の変更を検知する機能や効果音を鳴らす機能を定義する
データレイヤーを構成する
データレイヤーに相当する data
モジュールにおいて、提供する機能単位にパッケージ proximity
soundeffect
を作成します。各パッケージでは、インタフェースでメソッドの定義を行い impl
配下のクラスで具体的な実装を提供します。
たとえば、ProximityRepository.kt
は以下のように近接センサーの状態 ProximityState
と、センサー状態の監視の開始を指示するメソッド startTracking
を含むインタフェースを定義しているのみで、実装は DefaultProximityRepositoryImpl.kt
[2] で行います。
package ai.fd.thinklet.library.proximitychecker.data.proximity
import kotlinx.coroutines.flow.Flow
sealed class ProximityState {
data class CLOSE(val value: Float) : ProximityState()
data class AWAY(val value: Float) : ProximityState()
}
interface ProximityRepository {
fun startTracking(): Flow<ProximityState>
}
ここまで説明には登場していませんでしたが、DI(依存性注入)のために Hilt
を使用しており、 app
モジュール内のコードと data
モジュールの実装の紐づけは、 data
モジュール内にしれっと存在する RepositoryProvider.kt
で定義しています。本記事の中では(Hilt
の説明までは盛り込めないので)割愛します。実際のコードをご覧ください。
UIレイヤーを構成する
UIレイヤーは、ユーザーインタフェースとユーザーインタラクションを処理します。本アプリでは、UIレイヤーはMVVM(Model-View-ViewModel)パターンにしたがって実装しています。
-
Model:画面表示のためのデータを表現します。今回のアプリでは
ProximityState
が該当します - View:画面のUIを表示します
- ViewModel:Modelから取得したデータをViewに表示したり効果音を再生したりするための仲介役を担います。
Model
Modelである ProximityState
は、近接センサーの状態を表す sealed class
です。CLOSE
と AWAY
の2つのサブクラスを持ち、それぞれ「近い」状態と「遠い」状態を表します。
sealed class ProximityState {
data class CLOSE(val value: Float) : ProximityState()
data class AWAY(val value: Float) : ProximityState()
}
ViewModel
ViewModelは、ModelとViewをつなぐ役割を担います。さらに本アプリでは、ハードウェアキー入力を受け付けたり、ProximityState
の変化を受け効果音を鳴らす SoundEffectRepository
のメソッドをコールしたりもViewModelが担います。
近接センサーが関係する箇所のみ抜粋して見ていきましょう。
// import文は省略
@HiltViewModel
class MainViewModel @Inject constructor(
private val proximityRepository: ProximityRepository,
private val soundEffectRepository: SoundEffectRepository
) : ViewModel(), DefaultLifecycleObserver {
private val _proximityState = MutableStateFlow<ProximityState?>(null)
val proximityState = _proximityState.asStateFlow()
// 中略
fun startProximityTracking() {
viewModelScope.launch {
proximityRepository.startTracking().collectLatest { value ->
_proximityState.value = value
Log.i(TAG, "value = $value")
if (_isAppInForeground.value) {
soundEffectRepository.playSound(currentMountedStateToSoundType())
}
}
}
}
// ...
}
クラスの冒頭で宣言している _proximityState
は、近接センサーの値(ProximityState
)をViewに渡すためのFlowです。Flowについては後述しますので現段階ではViewに状態を通知する口だと理解してもらえれば結構です。宣言時は MutableStateFlow
としており MainViewModel
クラス内では値を書き込めるようにしていますが、View側では参照のみができるよう val proximityState = _proximityState.asStateFlow()
としてイミュータブルな形で公開しています。これにより、 _proximityState
の値が変化したときにView側ではその変化を検知して表示を切り替えられます。
実際のFlowへのデータの書き込み処理は startProximityTracking()
内で定義しています。proximityRepository.startTracking()
で返される値もFlow(Flow<ProximityState>
)だったことを思い出してください。このFlowから値の変化が通知されたときに行っている処理は、以下の通りです。
- 近接センサーの値が変化したときにこのFlowから値を受け取る
-
_proximityState
に変化後の値を設定 - (アプリがフォアグラウンドにあるときは)効果音を鳴らす
View
Viewは、ViewModelから提供された StateFlow
の変化を検知し、UIを更新します。関数内の1行目の記述によって、検知すべき情報を proximityState
という変数で受け取れるようにします。その後、受け取った状態によって画面表示で使う文言や色を分岐させています。コード中で Color
を直接指定するのはあまりよくなさそうですが、今回は簡易化するために直接指定します。
// import文は省略
@Composable
fun ProximityScreen(viewModel: MainViewModel) {
val proximityState by viewModel.proximityState.collectAsState()
// 中略
// 背景色とテキストカラーの決定
val (backgroundColor, textColor, statusText) = when (proximityState) {
is ProximityState.CLOSE -> Triple(
Color.Green,
Color.White,
"MOUNTED"
) // MOUNTED: 背景緑 + 黒テキスト
null -> Triple(Color.Gray, Color.White, "Waiting...") // 初期状態
else -> Triple(Color.Red, Color.White, "UNMOUNTED") // UNMOUNTED: 背景赤 + 白テキスト
}
ProximitySensorCheckerTheme {
// 画面表示の記述
}
}
これを実装すると、こんな画面ができあがります。近接センサーが反応していないときは背景が赤、反応しているときは緑色になります。(このスクリーンショットはPixel 4aで撮影しました)
ここまでで、Androidで推奨されるアーキテクチャに沿って、互いに疎結合なレイヤーに分け、責務の分離とデータの流れを構築することができました。近接センサーの値は、データレイヤーで一元的に管理され、データレイヤー -> ViewModel -> Viewの順に単方向データフローになるように構成されています。
Kotlin Flow の活用
ここまですでに用語としては出ていますが、本アプリではKotlin Flowを活用して、非同期で発生するデータストリームを効率的に処理しています。
-
Flow
:近接センサーの値の変化をViewModelで取得するために使用しています -
StateFlow
:状態を保持し、その状態の変化を通知するために使用しています -
callbackFlow
:コールバックで通知される近接センサーの値をFlowに変換するために使用しています
最後の callbackFlow
はここまでで未紹介でしたが、DefaultProximityRepositoryImpl
の中で以下の形で使用しています。
コールバックベースのAPIをFlowに変換する関数である callbackFlow
を使うと、センサーイベントのようにコールバックで通知されるイベントをFlowとして扱えます。これにより、Kotlin Coroutinesの機能を活用した非同期処理やストリーム操作が可能になります。
callbackFlow実装
override fun startTracking(): Flow<ProximityState> = callbackFlow {
if (proximitySensor == null) {
close()
return@callbackFlow
}
val sensorEventListener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent?) {
event?.let {
when (it.sensor.type) {
Sensor.TYPE_PROXIMITY -> {
trySend(proximityValueToState(it.values[0]))
}
else -> {}
}
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
// do nothing
}
}
sensorManager.registerListener(
sensorEventListener,
proximitySensor,
SensorManager.SENSOR_DELAY_NORMAL
)
awaitClose {
sensorManager.unregisterListener(sensorEventListener)
}
}
LLMの力を借りて作り切る
ここまでで、全体のアーキテクチャとその中の各レイヤー間でのデータの受け渡し方法などの理解が深まりましたね。これらの構成や使いたい機能の名称をうまくプロンプトに盛り込んで、あとはLLMに実装してもらいましょう。具体的には、以下の部分の実装づくりに大活躍してくれました。
- データレイヤーの
〜Impl
クラスの実装- Androidの作法にしたがって近接センサーから取得した値を
callbackFlow
を使ってFlowに変換する - 効果音を鳴らす、HWキー操作で音量を上げ下げするなどのお決まりの処理実装
- Androidの作法にしたがって近接センサーから取得した値を
- UIレイヤーの画面実装
- 「近接センサーの値の変更を画面に反映したいです。Jetpack Composeを活用し、画面全体を使ってわかりやすい表示にしてください。」のようなプロンプトで画面表示のデザインを実装してもらう
- 効果音ファイルの生成
現状のAIでは雑な依頼をすると雑な成果物が返ってくることがまだまだ多いですが、責務分離した上で具体的な用語を使って部分ごとに指示すれば、十分正確なコードを得られるのだと感じました。
おわりに
本記事では、近接センサーチェックアプリというシンプルなアプリの実装を通して、Android推奨アーキテクチャとKotlin Flowについて解説しました。見通しのよいアーキテクチャの構成を身に着けておくと、以後アプリ実装時に責務の分離について悩まなくて済みそうです。本記事の読者の方の中で、実際にTHINKLETに興味を持ってアプリ開発をしたい方の、アーキテクチャ設計の一助となれば幸いです。
今回のアプリ開発を通して、AI技術の進化によって開発の効率化が進む一方で、開発者自身はアーキテクチャ設計や適切なLLMへの指示といった、より高度な知識やスキルが求められるようになると感じました。今後も新しい技術を積極的に取り入れ、より質の高い開発ができる環境を作っていきたいと思います。
-
筆者が動作を試した機種(2機種)では
0.0
か5.0
かの2値をとるケースしかなかったのですが、他の機種ではより多いパターンの値を取るケースがあるとのことでした。機種別の実装を用意することがある想定で、「センサーが2値をとる場合をDefault」とみなしているためこの命名としています。 ↩︎
Discussion