mibot 車載ディスプレイのAndroidアプリ開発はどうなっているのか
はじめに
KGモーターズ株式会社で 副業エンジニア をしている だいま です。
開発しているmibot車載ディスプレイ用アプリはAndroidアプリとして作られています。
このアプリは、mibotに搭載される SBC(Single Board Computer)上で動作します。スピードメーターの表示、エアコン制御、音楽再生など、車両とユーザーを繋ぐインターフェースを提供しています。
実際に動かしている様子を一部↓のYoutubeからご覧いただけます!
この記事では、mibot車載ディスプレイ用アプリがどうなっているのか気になるAndroidアプリエンジニア向けに内部の構成について解説します!
アーキテクチャ概要
車載ディスプレイと言っても中身はほぼ普通のAndroidアプリになっています。
マルチモジュール構成でUIはJetpack Compose、Material3をベースに書かれています。
⚠️:アーキテクチャは常に変えても良いという考え・フェーズのため、来月にはここに書いている内容と実態が異なっている可能性はあります!ご留意ください!
現時点の構成を続けて書いていきます!
レイヤー構成
レイヤードアーキテクチャの考え方を採用しており、分け方は以下のようになっています。
┌────────────────────────┐
│ Presentation Layer │ ◀─ UI / ViewModel
└────────────────────────┘
┌────────────────────────┐
│ Application Layer │ ◀─ UseCase(ビジネスルール)
└────────────────────────┘
┌────────────────────────┐
│ Domain Layer │ ◀─ Types / Service Interface / Repository Interface
└────────────────────────┘
┌────────────────────────┐
│ Data Layer │ ◀─ Service実装 / Repository実装 / DataSource(AIDL・SDK)
└────────────────────────┘
補足:Serviceについて
本プロジェクトでアーキテクチャとして使用する「Service」は、Androidのバックグラウンドサービスとは異なります。
- 本プロジェクトのService: ビジネスロジックのインターフェースと実装(Flowベースのデータ監視、AIDL連携等)
- Android Service: システムコンポーネントとしてのバックグラウンド処理
混同を避けるため、本プロジェクトではServiceといえばビジネスロジックのServiceとして明確に区別しています。
モジュール構成
マルチモジュール構成 による責務分離とビルド最適化を行なっています。
モジュール構成をレイヤーと対にするかどうかが悩みポイントですが、現時点ではレイヤーとは異なる構成になっています。実用性・保守性・拡張性のバランスを取りながら適用しています。
featureモジュールの粒度は機能単位で整理し、機能で閉じるユースケースなどはそのfeatureモジュール配下に閉じるように配置させてサクサク開発できるようにしています。
アーキテクチャのレイヤー構成 = 論理、実際のモジュール構成 = 物理 のようになっています。
mibot-display-app/
├── app/ # エントリーポイント
├── application/
│ └── usecase/ # 共通ビジネスロジック
├── feature/ # 機能モジュール
│ ├── dashboard/ # メイン表示
│ ├── climate_control/ # 空調制御
│ ├── settings/ # 設定画面
│ └── ...
├── core/ # 共通基盤
│ ├── designsystem/ # UIコンポーネント
│ ├── di/ # 依存性注入
│ ├── common/ # 共通ユーティリティ
│ └── ...
├── domain/ # ドメイン層
│ ├── service/ # ビジネスロジック
│ ├── repository/ # データアクセス
│ └── types/ # 型定義
│ └── ...
└── data/ # データ層
├── datasources/ # 外部データソース
│ ├── aidl/ # AIDL通信
│ ├── ble/ # BLE通信
│ └── ...
├── service-impl/ # ビジネスロジック実装
└── repository-impl/ # データアクセス実装
論理レイヤー ⇔ モジュール対応表
レイヤー | 主なモジュール |
---|---|
Presentation Layer | feature/* |
Application Layer |
application/usecase/* , 一部 feature/*/*UseCase
|
Domain Layer |
domain/* (Types、Service Interface、Repository Interface) |
Data Layer |
data/* (Service実装、Repository実装、DataSource) |
共通部品(非レイヤー) | core/* |
その他開発系構成
プロジェクト管理:GitHub Project
CI: GitHub Actions, Firebase Test Lab
デザイン管理:Figma
ドキュメント管理:GitHub, Notion
コミュニケーション:Slack, GitHub, Notion
ターゲットOS:Android 14 (API 34)
主要技術スタック:
- UI: Jetpack Compose, Navigation Compose, Material3
- 非同期処理: Coroutines, Flow
- ネットワーク: OkHttp
- DI: Hilt
- テスト: JUnit, MockK, Turbine
- コード品質: ktlint
- ビルド: Gradle, KSP
ViewModel実装パターン
ViewModelは一方向データフロー(Unidirectional Data Flow)のように書くことを推奨しています。 ViewModel 実装例です。
ユーザー操作 ──▶ Event ──▶ ViewModel ──▶ UseCase/Logic ──▶ ViewState 更新 ──▶ UI 表示
▲ │
└─────────────────────── 外部イベント監視 ─────────────────────────────────┘
@HiltViewModel
class FanSpeedViewModel @Inject constructor(
private val useCase: FanSpeedUseCase,
vehicleService: VehicleService
) : ViewModel() {
private val _state = MutableStateFlow(FanSpeedState.OFF)
val state: StateFlow<FanSpeedState> = _state.asStateFlow()
init {
// 外部イベントの監視(車両からの状態変更)
vehicleService.observeEnvironmentChanges()
.onEach { event ->
event?.let {
if (it.type == EnvironmentType.FAN_SPEED_STATE) {
onEvent(FanSpeedEvent.ExternalStateChanged(FanSpeedState.fromInt(it.value)))
}
}
}
.launchIn(viewModelScope)
}
fun onEvent(event: FanSpeedEvent) {
when (event) {
// ユーザー操作: レベル1ボタンタップ
is FanSpeedEvent.ToLevel1 -> {
viewModelScope.launch {
useCase.setLevel1() // 車両に風量レベル1設定を送信
}
}
// 外部イベント: 車両からの状態変更通知
is FanSpeedEvent.ExternalStateChanged -> {
_state.value = event.state
}
// 初期化
is FanSpeedEvent.Initialize -> {
viewModelScope.launch {
_state.value = useCase.getCurrentState()
}
}
}
}
}
この例では
-
ユーザーがレベル1ボタンをタップ →
FanSpeedEvent.ToLevel1
イベント発生 - ViewModelがUseCaseを呼び出し → 車両に風量レベル1設定を送信
-
車両から状態変更通知 →
FanSpeedEvent.ExternalStateChanged
イベント発生 - ViewState更新 → UIに反映
このように、ユーザー操作と外部イベントの両方を一方向のフローで統一的に処理しています。
上記が全体の構成とViewModelの一例でした。
ここまで読めばAndroidアプリエンジニアなら大体どんな構成か掴めたのではないでしょうか??
普通のAndroidアプリ開発だということを感じていただけたのではないでしょうか?
特徴としては実用性・保守性・拡張性のバランスを絶妙にとっていて今のプロダクトのフェーズの特徴がそのままアーキテクチャに出てきている状態だと思っています!
mibot ディスプレイ用アプリ開発の特徴と面白いところ
続きまして、これまでモバイルやWebのソフトウェアエンジニアとして長年やってきた自分が個人的に考える mibot ディスプレイ用アプリ開発やっていて面白いなと思うポイントを6つ紹介します。
面白ポイント1: 車両と物理的に繋がる開発
車両状態を取得できるCanとAndroidアプリを繋ぐ境界はAIDLが定義され、AIDL越しに行われています!
車載アプリ開発では、実機(SBC)とシミュレーターの両方で開発する必要があります。しかし、CANは実機でのみ動作するため、開発効率が低下する問題がありました。
そのため、シミュレーターで開発する時はAIDLを継承したMockサービスを作り、Mockで開発するようにしています!
単純なモックではなく、実際の車両データのような動的な変化を再現することで、よりリアルな開発環境を構築しています。
以下はモックのイメージが掴めるサンプルです。
@Singleton
class MockCarControlService @Inject constructor() : AIDLのインターフェース.Stub() {
// 動的な値更新で動作を模倣。面白
private fun startDynamicValueUpdates() {
speedUpdateJob = coroutineScope.launch {
while (isActive) {
delay(200)
updateVehicleSpeed()
}
}
}
private fun updateVehicleSpeed() {
vehicleSpeedMock += if (isSpeedIncreasing) 1 else -1
if (vehicleSpeedMock >= 99) isSpeedIncreasing = false
else if (vehicleSpeedMock <= 0) isSpeedIncreasing = true
triggerSpeedStateChanged(vehicleSpeedMock)
}
}
関連して実機でCANのシミュレートという面白ポイントもあります。こちらは別記事がありますのでたーそさんの記事をご覧ください!
面白ポイント2: スタートアップならではの柔軟性と越境
上記で書いたアーキテクチャも常に変化し続けられる状態です!
ソフトウェア面での工夫を思いついた時にサッと実装できる点は、未リリース、少人数開発ならではのフェーズの面白さだと思います。
また、ソフトウェアだけを開発してては得られない学びが多くあります。車体開発や電装チームなど多岐にわたる背景の人たちと協力しつつmibotそのものの開発をしていきます。異なる背景を持った人と、お互いに学びつつ一つの製品を開発でき、ソフトウェア面や文化から貢献できる領域の多さと自らを成長できる機会の多さが魅力です。
面白ポイント3: 実世界と繋がるAndroidアプリ開発
車両の状態だけでなく、AndroidのSDKを通してBluetoothやWifiなど実世界と繋がるモジュールを駆使したユーザー体験を向上できる点はAndroidアプリエンジニアならみんな好きなのではないでしょうか?え?違いますか??もちろん好きですよね???
WiFi状態監視の例
@Singleton
class WifiStatusServiceImpl @Inject constructor(
@ApplicationContext private val context: Context
) : WifiStatusService {
private val _wifiStatus = MutableStateFlow(getCurrentWifiStatus())
override val wifiStatus: StateFlow<WifiStatus> = _wifiStatus.asStateFlow()
init {
registerNetworkCallback()
registerWifiReceiver()
}
private fun registerNetworkCallback() {
val networkRequest = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.build()
networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
updateWifiStatus()
}
override fun onLost(network: Network) {
updateWifiStatus()
}
}
}
}
面白ポイント4: 固定端末ならではの最適化
通常Androidアプリを開発をする際は、あらゆるOSバージョン、あらゆるサイズの端末に対して考えて実装しないといけないですが、mibotの場合は動かす端末は決まっていますので、特注にできます。
もちろん今後の物理端末を変更した場合にも追従できるように柔軟に配慮して作りますが、割り切るところは割り切るということが可能になっていて迅速な開発が可能です。
逆にデメリットもあって、車載環境の制約(CPU性能、メモリ制限)を考慮した最適化を行っています。
近頃のAndroid搭載スマホとして売っている端末とまではいかない、mibotの要件に最低限必要なマシンスペックのため富豪プログラミングは通用しないことがあります。
シミュレーターでは普通に動くけど、実機を使うと重たいなんてことが発生します。
むしろ個人的にはコードで課題を解決できるとっても面白い領域なのではと思っています!
面白ポイント5: 車載特有のUI/UX設計
運転中の操作を考慮した大きなタッチターゲットや視認性を重視したコントラスト、アニメーション設計がなされています。またそのUI/UXを考える際に車そのものについて調べる機会も多く、車そのものにも詳しくなれます。車へ関心があれば楽しむことができます。
面白ポイント6: Android OS領域への踏み込み
OSに組み込まれシステム権限を有するアプリになるため、一般的なアプリ開発では触れない、Android OSの深い部分まで踏み込んでいます。Androidアプリエンジニアとしてスキルを高め続けると最終的にたどり着くのはAndroid OSそのものになるはず。
Androidアプリエンジニアとして最高にスキルアップできる魅力があります!!
まとめ
車載ディスプレイ用アプリは、Androidアプリ開発の様々な面白い要素が詰まったプロジェクトです!
- Jetpack Composeによる宣言的UIによる開発
- AIDLによる車体との連携
- BLE・WiFi(他にも色々) などの外部デバイス通信
- CI/CDによる品質保証。将来的にはmibot車体を使ったCIすることになるかも!?
- ハードウェア制約への対応
- Android OS領域への踏み込み
特に、物理的なハードウェアと連携するため他の技術領域の人と協力することで、ソフトウェアだけでは体験できない「ものづくり」の楽しさを味わえるのが、このプロジェクトの最大の魅力です。
スタートアップならではの身軽さを保ちながら、品質も妥協しない。そんな開発環境を実現しているプロジェクトの一端を紹介できて嬉しいです。
車載アプリ開発に興味がある方、ハードウェア連携に挑戦したい方、ぜひ参考にしてみてください!
Discussion