フル JetpackCompose 製のアプリ『TUNAG 受付』をリリースしました

こんにちは!

スタメン TUNAG 事業部 モバイルアプリグループのカーキです。

最近では主として Android アプリの開発に携わっています。

株式会社スタメンでは7月の初めに『TUNAG 受付』という、TUNAGのチャット機能を利用したオフィスの受付アプリをリリースしました(Androidのみ対応) (ストアリンク

『TUNAG 受付』はUIの部分を全て Jetpack Compose で記述しており、アプリ全体の構成もJetpack Composeに合わせたものになっています。

(過去のJetpack Compose導入の経緯などはこちらのブログをご参照ください) 今回は『TUNAG 受付』のアーキテクチャ及び、その構成について紹介します。

TUNAG 受付について

まず初めに『TUNAG 受付』がどのようなサービスかについて紹介します。

「TUNAG 受付」では、主に以下の2つの機能を提供しています。

  • オフィスに訪れた来訪者が担当の社員を呼び出す
  • 社員はTUNAGチャットでその呼び出しを確認できる

TUNAG受付とTUNAGのアプリケーション間の関係

「TUNAG 受付」のアプリとしてはTUNAGのチャットの仕組みを使って、「オフィスの来訪者が担当の社員を呼び出せる」機能を提供しています。

受付アプリを自社で開発をしたきっかけとしては、今年4月のオフィス移転があります。 新オフィスでは執務スペースが2階にあり、出入り口のある1階に来訪の方が来られた時の対応を検討していました。 他社のサービスも検討したのですが、TUNAGのユーザー情報とチャットの通知機能を使うことで、スタメンらしさを出すことができるのでは?という話があり、自社で受付アプリの開発を行うことになりました。

アーキテクチャ

TUNAG受付は、Androidタブレット端末向けに既にリリースされています。 開発時にはちょうど Jetck Compose を用いた開発が社内でも盛り上がっており、UIの部分に関しては全て Jetpack Compose で開発を行いました。

また今後iOSアプリへの拡張性を考慮し、データ及びドメイン層には Kotlin Multiplatform Mobile(通称:KMM)を採用しています。 今回は JetpackCompose をどのように活用しているかにフォーカスを絞って紹介しますので、KMMに関しては詳しくは触れません。

全体的なアーキテクチャとしてはMVVMを採用しています。 TUNAG受付全体の構成はこのようになっています。

アプリ全体のアーキテクチャ

社内では既に新規のコンポーネントの作成には Jetpack Compose を使っていましたが、Activityをほとんど用いないフル Jetpack Compose の開発は初めてだったので、Google が公開している Jetpack Compose の公式サンプルアプリのアーキテクチャと Android アプリアーキテクチャガイドを参考にしています。

UI層

Googleのアプリアーキテクチャガイドでは、UIレイヤは「Viewを構成するUI elements」と「Viewの状態を管理するState holders」によって構成されています。

Google のアーキテクチャガイドにある UI 層の構成

一般的にUI elementsでは、View や Compose が、State holders は ViewModel が該当します。

UI State の不変性

この View の状態管理の方法について、アプリアーキテクチャではUI Stateという概念が登場しています。 UI State は UI の表示を一意に決定することのできる状態を持っています。

UI State の使用が推奨されている大きな理由は、その不変性にあります。UIレイヤーにおける不変性とは、データの状態により UI が一意に決まることを指します。 ここで UI elements は直接 UI の状態を書き換えることはできず、必ず State holders から受け取った状態のみを UI へ反映します。このようにすることで State holders は確実に UI の状態を把握することができ、UI elements は受け取った状態のみを UI に反映するだけで良いので、関心の分離を実現することができます。

受付アプリのUserListScreenでのUI State は以下のように記述されています。

/**
 * UserList - UIState
 */

sealed interface UserListUiState {

    val isLoading: Boolean

    val errorMessage: String?

    val currentRoom: RoomDetailDto?

    /**
     * ルームにユーザーが存在しない
     */
    data class EmptyUser(
        override val isLoading: Boolean,
        override val errorMessage: String?,
        override val currentRoom: RoomDetailDto?,
    ) : UserListUiState

    /**
     * ユーザーが存在する場合
     */
    data class HasUsers(
        override val isLoading: Boolean,
        override val errorMessage: String?,
        override val currentRoom: RoomDetailDto,
        val userOnCall: UserStateHolder,
        val isOpenCallDialog: Boolean,
    ) : UserListUiState
}

受付アプリのUserListScreenでは以下の2つの状態があり、sealed interfaceで定義することにより、状態を完全に切り分けています。 - EmptyUser - ルームにユーザーが存在しない場合は「ルームにメンバーが存在しません。」のように空の状態を表示 - HasUsers - ルームにユーザーが存在する場合は、メンバー一覧を表示

UI elements における UI State の扱い

UI State は sealed class にて状態がただ一つに決定されるため、 UI State は状態に合わせて適切な UI を構成することができます。 UserListScreenのような UI elements を担うコンポーザブル関数では以下のように状態に合わせて UI の状態を変えて表示しています。

@Composable
fun UserListScreen(
    uiState: UserListUiState,
    ...
) {
    
    when (uiState) {
        UserListUiState.EmptyUser -> EmptyUserList()
        UserListUiState.HasUsers -> UserList()
    }
}

ViewModel内におけるUI Stateの扱い

ViewModel内では表示するデータ全般を扱う別のデータクラスの操作を行い、UI Stateの操作はViewModelでは直接は行いません。 ViewModelStateがViewModelでのデータ全般を扱い、ViewModelStateの変更がそのままUI Stateに通知されるようにしています。

このような ViewModelState を使用した ViewModel 内での状態管理は Google が公開している Jetpack Compose のサンプルアプリであるJetNews の実装を参考にしています。

ViewModel内における状態管理を全てViewModelStateが担うメリットとしては、 UIState の状態を個別の値の変化によっていちいち変更する必要がないことが挙げられます。 これはアーキテクチャガイドにも考慮事項として記述されています。

private data class UserListViewModelState(
    val currentRoom: RoomStateHolder = RoomStateHolder(state = State.Initial),
    val userOnCall: UserStateHolder = UserStateHolder(state = State.Initial),
    val errorMessage: String? = null,
    val isOpenCallDialog: Boolean = false,
) {

    /**
     * UiStateへの変換
     */
    fun toUiState(): UserListUiState {
        return when (currentRoom.state) {
            is State.Data -> {
                if (currentRoom.state.data?.users?.isNotEmpty() == true) {
                    UserListUiState.HasUsers(
                        isLoading = false,
                        errorMessage = errorMessage,
                        currentRoom = currentRoom.state.data!!,
                        userOnCall = userOnCall,
                        isOpenCallDialog = isOpenCallDialog,
                    )
                } else {
                    // メンバーが存在しない場合
                    UserListUiState.EmptyUser(
                        isLoading = false,
                        errorMessage = "呼び出し先がありません。設定してください。",
                        currentRoom = currentRoom.state.data,
                    )
                }
            }
            else -> {
                // 読み込み中・エラーなどでメンバーが表示されていない場合
                UserListUiState.EmptyUser(
                    isLoading = currentRoom.state is State.Loading,
                    errorMessage = errorMessage,
                    currentRoom = null,
                )
            }
        }
    }
}

このUserListViewModelStateはViewModelの内部での全ての状態と、UI elements へ公開される UI State (ここではUserListUiState)へマップするメソッドを持っています。

ViewModelの内部では下記のように MutableStateFlow でラップされています。 MutableStateFlowとして扱えることで、StateFlowのupdateメソッドから、状態の更新を行うことができます。

class UserListViewModel() {

    private val viewModelState = MutableStateFlow(UserListViewModelState())
    
    fun hoge() {
        viewModelState.update { it.copy(isOpenCallDialog = true) }
    }
}

またViewModelから外部に公開される UI State はStateFlowの持つメソッドにより、viewModelStateに更新がかかるたびに順次更新されるように実装を行なっています。

/**
 * 外部に公開されるUiState
 */
 val uiState = viewModelState
    .map { it.toUiState() }
    .stateIn(
        viewModelScope,
        SharingStarted.Eagerly,
        viewModelState.value.toUiState()
    )

画面遷移

画面遷移に関しては、NavHost を利用しています。 こちらに関してはDvelopersのドキュメント通りの実装になるので、軽く紹介します。

NavHost ではコンポーザブルの目的地を指定することで対象となるコンポーザブルを呼び出すことができます 以下は公式ドキュメントのサンプルコードになります。

NavHost(navController = navController, startDestination = "profile") {
    composable("profile") { Profile(/*...*/) }
    composable("friendslist") { FriendsList(/*...*/) }
    /*...*/
}

実際には、画面間の値の受け渡しなどが発生するので以下のように実装を行なっています

NavHost(
    navController = navController,
    startDestination = startDestination,
    modifier = modifier,
) {

    composable(ReceptionDestination.GroupList.route) {
        val viewModel: GroupListViewModel = viewModel(
            factory = GroupListViewModel.provideFactory(container)
        )
        GroupListRoute(
            groupListViewModel = viewModel,
            navigateToUserList = { navigationActions.navigationToUserList(it) },
        )
    }

    composable(
        ReceptionDestination.UserList.route,
        arguments = listOf(navArgument("roomId") { type = NavType.LongType })
    ) {
        val viewModel: UserListViewModel = viewModel(
            factory = UserListViewModel.provideFactory(
                container,
                it.arguments?.getLong("roomId") ?: 0L
            )
        )
        UserListRoute(
            userListViewModel = viewModel,
            back = navigationActions.back,
        )
    }
}

遷移先のコンポーザブルが呼び出されるタイミングで ViewModel の注入を行なっています。

まとめ

「TUNAG受付」がどのようにフルJetpack Compose の構成で実装を行なっているのかを紹介しました。 現状「TUNAG受付」は小さなアプリケーションとなるので、複雑さは少なく、Google が提供している公式のツール、方法でやりくりすることができています。 スモールスタートで開発を進める上で、JetpackCompose や Google のアーキテクチャガイドは良い指針となりました。

弊社では、JetpackCompose に興味のある Android エンジニアを募集しています。 気になる方、興味のある方は、ぜひ弊社採用窓口までご連絡ください。

TUNAGの開発体制、使用技術については下記ブログ記事を参照ください。

TUNAGの技術と開発体制のすべてを紹介します!

また弊社では、オンラインサロン用プラットフォーム FANTS というサービスも開発・運営しております。 ご興味がある方はぜひ下記のリンクをご覧ください。

FANTSの開発技術・開発組織を紹介します!

またスタメン Engineering Handbookとして体系的にまとめられたページも公開しています。 こちらも是非ご覧ください。