ベータコンピューティングの活動や技術、開発のこだわりなどを紹介するブログです。



端末管理アプリ ~ 機能紹介 ~

この記事について

こんにちは、Beta Computing株式会社ソフトウェア開発者の尾崎です。
今回、社内で利用する業務改善用のiOSアプリを開発しました。
この記事ではアプリの概要や機能について紹介したいと思います。
(技術的な内容は次の記事でお話します。)

blog.betacomputing.co.jp

背景

弊社は主にスマートフォン向けアプリを開発しているので、テストや動作確認のためにスマホ・タブレット端末をいくつも所有しています。
各端末は開発時の状況に応じてOSバージョンを変更したり、経年劣化による不調が見られたり(バッテリーの劣化、ボタンの反応が悪いなど)するのですが、これまでは表計算シートや口頭ベースで管理しており、各端末の状態の把握が煩雑でした。
また、開発中のテストや動作確認などでリモートワーク中の社員に端末を貸し出すことがありますが、こちらもチャットアプリや口頭ベースで管理していたので、誰がどの端末をいつから借りているかという状況が見えづらいという問題がありました。
そこで上記の問題を解消するため、今回は端末の状態と貸出状況を管理するアプリを開発しました。

アプリの概要

本アプリは以下の3つのタブから構成されます。

  • 端末一覧タブ
    • 所有する端末一覧をリスト表示します。
    • 新規端末の追加や詳細情報の確認、編集はこの画面から行います。
  • 貸出状況タブ
    • 貸出中の端末一覧をリスト表示します。
    • 貸出申請や返却処理はこの画面から行います。
  • 設定タブ
    • 貸出申請するユーザ(社員)情報の登録など各種設定を行います。

この構成のねらいは、メイン機能である『端末情報の管理』と『貸出状況の管理』を別々の画面に切り分けることで1画面の情報量が増えすぎるのを抑え、ユーザ操作をできるだけシンプルにすることです。
また、管理される各端末にはNFCタグを貼って端末情報を保存できるようにします。これによりスキャンするだけで端末情報や貸出状況を取得可能にし、申請時の操作がより簡潔になることを目指します。

必要なもの

  • 端末管理アプリをインストールするiPhone(iOS16+)
  • NDEF読み取り・書き込みが可能なNFCタグ

アプリの機能、使い方

以下に各画面の機能を紹介いたします。
(画面は開発中のものです)

端末一覧

端末一覧画面はサーバに保存された端末情報がリスト形式で表示されます。各セルには端末名、OSバージョン、シリアル番号と備考が表示され、状態が正常、故障、廃棄の3パターンで色分けされています。また、端末種類別のグループにセクションが分けられています。
①のフィルタボタンをタップすると表示設定画面が立ち上がり、表示する端末の条件と並び順を設定できます。

②の端末追加ボタンをタップすると新規端末の追加画面が立ち上がり、端末情報を入力して保存することができます。
③の端末情報セルをタップすると端末詳細情報画面へ遷移します。

端末詳細情報

①の端末情報では端末一覧画面で表示していない詳細な情報まで確認できます。

②の更新履歴には、端末情報を編集すると変更前後の値が表示されます。

③の編集ボタンをタップすると端末情報を編集画面が立ち上がります。
④のNFCスキャンボタンをタップするとモーダルが立ち上がり、端末情報をNFCタグへ書き込むことができます。端末情報を書き込んだタグを貼ることで、貸出/返却申請時のユーザ操作を簡略化することがねらいです。
⑤の戻るボタンをタップすると端末一覧画面へ戻ります。

貸出中端末

貸出中端末画面には貸出中の端末情報、ユーザ名、貸出日がリスト表示されます。

①の申請ボタンをタップすると、貸出返却処理メニューが表示されます。
NFCスキャンをタップするとモーダルが立ち上がり、端末に貼られたNFCタグを読み取ることで貸出/返却画面を起動することができます。

手動で選択をタップすると貸出端末選択画面が立ち上がり、貸出可能な端末のみが一覧表示されます。
右上のフィルタボタンをタップすると表示設定画面へ遷移し、表示する端末の条件を設定することができます。

手動で選択したときと、読み取ったNFCタグが貸出中でないときは貸出申請画面が立ち上がります。
②の貸出情報セルをタップしたときと、読み取ったNFCタグが貸出中だったときは返却画面が立ち上がります。申請または返却を実行するとサーバと通信し、貸出状況を保存します。
③の履歴ボタンをタップすると、貸出履歴画面へ遷移します。

貸出履歴

図: 貸出履歴画面

①には返却済みの端末情報、ユーザ、貸出日、返却日がリスト表示されます。

図: 貸出履歴フィルタ

②のフィルタボタンをタップするとフィルタメニューが表示され、表示する履歴の条件を指定できます。
③の戻るボタンをタップすると貸出中端末画面へ戻ります。

設定画面

図: 設定画面

①のユーザ設定をタップすると、社員の氏名を登録できる画面に遷移します。
②のNFCタグのデータ消去をタップすると、スキャンしたタグの中身を削除する操作が可能です。これによりNFCタグの再利用性を高めるのがねらいです。
③にはアプリのバージョンが表示されます。

使ってみて

現在弊社が所有する約40~50台ほどの端末をアプリに登録しましたが、一覧画面でグループ別に表示したりOS順に並べかえたりという機能は非常に使いやすいと感じます。従来は表計算シートで管理していましたが、情報の最終更新日が不明確ということもあり、シートを作成してから日が経つほど信頼性が薄れるという懸念点もありましたが、今回作成したアプリでは登録日と更新履歴が記録されるため、その点も改良されたように思います。また、社内では更新履歴に変更前のデータが表示されるという機能も好評でした。
一方、運用上のルールとしてアプリでの管理だけでなく、端末そのものにモデル名を記載したテプラも貼ることとしました。これにより更新や貸出の際に端末を探し出す時間をさらに削減することが目的です。
ソフトとハードの両方からアプローチすることで、より効果的に業務改善できたように思います。

おわりに

今回作成したアプリは社内用ということもあり、シンプルなデザインで開発いたしました。基本的にUIやアイコンはiOS標準のものを利用しています。(副産物的ですが、おかげで勝手にダークテーマにも対応できているのは後々になって気づきました。)ViewはSwiftUIで実装しましたが、よりiPhone標準アプリっぽい仕上がりになったと感じます。

また、今回はスマホ・タブレット管理用にデータベースや入力欄を構成しましたが、応用すればPC端末や他の機材類なども管理できるアプリが開発できると思います。
もし、似たようなお悩みを抱えている方がいらっしゃれば、弊社にご協力できることがあるかもしれません。お気軽にお問い合わせください。

次の記事では、技術的な面をお話ししたいと思います。

Jetpack Compose入門 ~ 実践編 ~

この記事について

こんにちは、Beta Computing株式会社で学生アルバイトをしていますAndroidアプリケーション開発担当です。 来年4月から正式に入社予定ですので、現在は研修期間としてAndroid開発の勉強を進めています。 今回はAndroid開発のツールキットであるJetpack Composeを用いてアプリを作成する上で、勉強になった点をまとめたいと思います。

今回作成したアプリについて

今回は買い物メモをリストにして管理するアプリをJetpack Composeを用いて作成しました。

Hilt (Jetpack Composeにおける使用)

Hilt(ヒルト)は依存性(オブジェクト)の注入を簡素化することができるライブラリです。 手動で依存性注入を行う方法に比べて、Hitlを使うことで、 依存関係のスコープをライフサイクルに合せて自動的に管理することが可能になったり、 依存関係の検証によって不足している依存関係や不適切なスコープ設定を早期に発見できたりすることなどのメリットを享受できます。 以前の記事で概要を簡単に紹介しましたが、今回はHiltの使用方法を記事にまとめてみようと思います。 ※ 今後、それぞれのライブラリの導入については、環境によって方法が異なるため、省略いたします。 公式ドキュメントは以下になります。

Hilt を使用した依存関係挿入  |  Android Developers

Hiltを使う準備

まずはアプリケーションクラスを作成して @HiltAndroidAppアノテーションを付けます。

app/…/shoppingmemocompose/ShoppingMemoComposeApplication.kt

import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber

@HiltAndroidApp // 依存関係注入に必要
class ShoppingMemoComposeApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // ログ出力の設定
        if (BuildConfig.DEBUG) {
            Timber.plant(Timber.DebugTree())
        }
    }
}

このクラスをはじめて作る場合はAndroidManifest.xmlに以下の以下の記述が必要です。

app/src/main/AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <application
       ...
        android:name=".ShoppingMemoComposeApplication" <!-- こちらを追加 -->
        tools:targetApi="31">
    </application>
...
</manifest>

また、Hiltを利用するコンポーネントには @AndroidEntryPoint を付与します。 今回の場合は MainActivityに付与します。

app/…/shoppingmemocompose/ui/main/MainActivity.kt

@AndroidEntryPoint // こちらを追加
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ShoppingMemoComposeTheme {
                ShoppingMemoComposeApp()
            }
        }
    }
}

基本的は以上で準備完了です。

実際に依存注入を行う

Hiltを用いて依存性注入を実現する方法を以下にまとめます。 ここでは、具体例としてリポジトリクラスを注入する手順を説明します。

Hiltでは、クラスのインスタンスを生成する際に依存関係を解決するため、対象のクラスに@Injectアノテーションを付けます。 たとえば、リポジトリクラスを作成する場合を見てみます。 以下のように、リポジトリクラスのコンストラクターに@Injectを付与します。

※ DAOなどについてはRoom(データベース)の章で説明します。

class ShoppingMemoRepository @Inject constructor(
    private val converter: ShoppingMemoConverters, // 依存関係
    private val appDatabase: AppDatabase, // 依存関係
) {
    // DAOの取得
    private val shoppingMemoDao = appDatabase.getShoppingMemoDao()

    /**
     * 買い物メモを新規作成する。
     */
    suspend fun createNewShoppingMemo(): ShoppingMemoId {
        val defaultTitle = "無題"
        val shoppingMemoDate = null
        val shoppingMemoId = shoppingMemoDao.insertMemo(
            ShoppingMemoEntity(
                title = defaultTitle,
                date = shoppingMemoDate
            )
        )
        return converter.toShoppingMemoId(shoppingMemoId)
    }


    /**
     * 買い物メモを削除する
     */
    suspend fun deleteShoppingMemo(
        shoppingMemoId: ShoppingMemoId
    ) = shoppingMemoDao.deleteShoppingMemo(shoppingMemoId)

    /**
     * 買い物メモのタイトルを更新する
     */
    suspend fun updateShoppingMemoTitle(
        id: ShoppingMemoId,
        newTitle: String
    ) = shoppingMemoDao.updateTitle(id, newTitle)

    /**
     * 買い物メモの日付を更新する
     */
    suspend fun updateShoppingMemoDate(
        id: ShoppingMemoId,
        newDate: ShoppingMemoDate?
    ) = shoppingMemoDao.updateDate(id, newDate)

    // ... 以下メソッドが続く

}

場合によっては、インターフェイスや外部ライブラリのクラスなど、 直接コンストラクタインジェクションができないものがあります。 その場合は、Hiltモジュールを作成して依存性を提供します。 モジュールを作成するには、@Module@InstallInアノテーションを使用してモジュールを定義します。 また、スコープ設定によって依存性のライフサイクルを管理できます。 たとえば、アプリ全体で同じインスタンスを共有したい場合は、@Singletonスコープを使用します。 以下はその例です。

// Hiltによる依存性注入の設定
@Module
@InstallIn(SingletonComponent::class)
class AppDatabaseModule {

    // データベースのインスタンス(データベースの章で説明します。)
    @Provides // このアノテーションが必要
    @Singleton
    fun provideDatabase(
        @ApplicationContext context: Context
    ): AppDatabase = AppDatabase.getDataBase(
        context
    )

    @Provides
    @Singleton
    fun provideShoppingMemoConvertors(): ShoppingMemoConverters = ShoppingMemoConverters

    // ... 
}

ViewModelへの依存性注入

ViewModelに対してもHiltで依存性注入が可能です。@HiltViewModelアノテーションとコンストラクタインジェクションを使用することで依存性が注入できます。 以下は例です。

@HiltViewModel
class DetailViewModel @Inject constructor(
    private val repository: ShoppingMemoRepository
) : ViewModel() {
    // データベースの操作などを記述
}

さらに、ComposeでHilt管理下のViewModelを使用するには、hiltViewModel()関数を利用します。

@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class)
@Composable
fun DetailScreen(
    navigateBack: () -> Unit,
    viewModel: DetailViewModel = hiltViewModel() // ここに依存性注入
    // ...
) {
    Scaffold(
        topBar = {
            CenterAlignedTopAppBar(
                title = { Text("買い物詳細") },
                actions = {
                    TextButton(
                        onClick = viewModel::showDeleteConfirmDialog,
                        enabled = !detailUiState.isLoading
                    ) { Text("削除") }
                },
                navigationIcon = {
                    IconButton(
                        onClick = navigateBack,
                        enabled = !detailUiState.isLoading
                    ) {
                        Icon(
                            imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                            contentDescription = "もどる"
                        )
                    }
                },
                colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
                    containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
                )
            )
        },
        floatingActionButton = {
            ExtendedFloatingActionButton(
                onClick = {
                    if (!detailUiState.isLoading) {
                        viewModel.showItemInputDialogOnCreateMode()
                    }
                },
                icon = { Icon(Icons.Filled.Add, "品物追加ボタン") },
                text = { Text(text = "品物追加") }
            )
        }
    ) { paddingValues ->
        LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .padding(horizontal = 0.dp) // 水平方向のパディングを0に
                .consumeWindowInsets(paddingValues), // この行を追加
            contentPadding = paddingValues, // innerPaddingをcontentPaddingとして設定,
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Top
        ) {
            // LazyColumnの内容が続く...
        }
    }
}

StateFlow

まずFlowとは?

StateFlowを説明する前に、Flowについて説明します。 Flowとは「コルーチンの一種で、返す値が1個のみの「suspend関数」とは異なり、複数の値を順次出力できます。」 と公式ドキュメントで紹介されています。

Android での Kotlin Flow  |  Android Developers

今回の実装では、Flowを用いてデータベースから値をリアルタイムで取得しました。 たとえば以下のコードを見てください。

fun getProcessedData(): Flow<List<String>> = flow {
    val rawDataFlow: Flow<List<Int>> = getRawDataFlow() // 仮のデータソース(Flowで整数のリストを提供)

    rawDataFlow.collect { rawList ->
    
        val filteredList = rawList.filter { it % 2 == 0 }
        val processedList = filteredList.map { "Number: $it" }

        // 加工済みのデータをemitで発行
        emit(processedList)
    }
      // 仮のデータソース(Flowを生成)
    private fun getRawDataFlow(): Flow<List<Int>> = flow {
        emit(listOf(1, 2, 3, 4, 5)) // 最初のリストを発行
        delay(1000) // 次のデータまで1秒待機
        emit(listOf(6, 7, 8, 9, 10)) // 次のリストを発行
    }
}
  • flow {}

    新しいFlowを構築するためのブロックであり、非同期データストリームを生成する役割を持っています。

  • .collect {}

    上流のFlowからデータを収集し、そのデータを順次処理するために使用されています。

  • emit()

    Flow内で新しい値を発行し、下流に向けてデータを送信するための操作です。

また、Flowはコールドストリームと呼ばれるストリームです。 Flowは.collect()を呼び出さないと値を収集することはありません。 それに対して、次に紹介するStateFlowはホットストリームと呼ばれるストリームです。

StateFlowとは?

StateFlowは、Kotlin Coroutinesの一部として提供される「ホットストリーム」の一種です。 通常のFlowはコールドストリームであり、収集が開始されるまで値を生成しませんが、 StateFlowは常に最新の値を保持し、新しいコレクターが収集を開始した瞬間にその値を受け取ることができます。

コールドストリームとホットストリームの違いについては以下のサイトが分かりやすくまとめられていて、大変勉強になりました。 初学者向けKotlin Coroutines Flow

class DataProcessor {

    // MutableStateFlowを定義(初期値は空のリスト)
    private val _processedData = MutableStateFlow<List<String>>(emptyList())

    // 外部からはStateFlowとして公開
    val processedData: StateFlow<List<String>> get() = _processedData

    // 以下は先ほどのコードとほとんど同じ
    fun startProcessing() {
        val rawDataFlow: Flow<List<Int>> = getRawDataFlow() // 仮のデータソース

        CoroutineScope(Dispatchers.Default).launch {
            rawDataFlow.collect { rawList ->
                val filteredList = rawList.filter { it % 2 == 0 }
                val processedList = filteredList.map { "Number: $it" }
                _processedData.value = processedList
            }
        }
    }

    // 仮のデータソース(Flowを生成)
    private fun getRawDataFlow(): Flow<List<Int>> = flow {
        emit(listOf(1, 2, 3, 4, 5)) // 最初のリストを発行
        delay(1000) // 次のデータまで1秒待機
        emit(listOf(6, 7, 8, 9, 10)) // 次のリストを発行
    }
}

以上の様にStateFlowを用意することで、常に最新の値を取得できます。

FlowとStateFlowについては以下のサイトが大変勉強になりました。 なんとなく使いこなしてた気がしてた、StateFlowを理解する

また、StateFlowと似たものにLiveDataと呼ばれるものがあります。 LiveDataは初期値が必要ないのに対して、StateFlowは初期値が必要でNull安全です。 細かな違いは以下のサイトの中程で説明がされています。

StateFlow と SharedFlow | Android Developers

Room(+DAO、DTO等の話)

Roomとは、Android Jetpackに含まれているライブラリで、SQLiteデータベースの操作を簡潔に行えるようにする抽象レイヤーを提供してくれます。 Roomを導入することで、コード量を削減しつつ、 Flowとの連携も簡単になるため、安全で効率的なデータベース操作が可能になります。 公式ドキュメントは以下です。

Room公式ドキュメント |  Android Developers

また、本章の内容は以下に沿っています。

Room を使用してデータを永続化する |  Android Developers

Entityの用意

Entityとは、Roomにおいてデータベースを表現するクラスです。 Entityクラスを作るには作成したデータクラスに @Entity アノテーションを付けます。 このクラスを用意することで、Room側がテーブル構造を認識して自動的にSQLiteテーブルを作成してくれます。 たとえば以下のようなEntityクラスです。

@Entity(tableName = "shopping_memo_tbl")
data class ShoppingMemoEntity(
    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "shopping_memo_id")
    val id: Int = 0,
    @ColumnInfo(name = "shopping_memo_title")
    val title: String,
    @ColumnInfo(name = "shopping_memo_date")
    val date: String?
)

このクラスは買い物メモのテーブルです。 テーブル名は @Entity(tableName = "shopping_memo_tbl") で定義されていて、ID、買い物メモのタイトル、買い物メモに紐付いた日付がフィールドとして定義されています。 さらに、IDは主キー(@PrimaryKey アノテーション)かつ自動生成(@PrimaryKey(autoGenerate = true))を指定しています。 このように指定することで、レコードが生成されるたびに新しいIDを生成してくれます(主キーはレコードを区別するためにテーブル内で一意である必要があります)。 さらに今回は、@ColumnInfo(name = "○○") アノテーションを使用して、データベースにおけるフィールド名を指定しています。 その他のアノテーションについては公式ドキュメントをご参照ください。

Entity |  Android Developers

外部キー制約付きEntity

テーブルによっては外部キー制約を定義する必要があります。外部キー制約とは、他のテーブルとの関係による制約です。 たとえば、買い物メモに複数の品物メモを追加する場合を考えます。1つの買い物メモに複数の品物メモを登録する場合、買い物メモと品物メモの関係は1対多になります。

  • キャンプ用品(買い物メモ)
    • ランタン(品物メモ)
    • テーブル(品物メモ)
    • ...

このとき、何の制約も無く買い物メモを削除するとどうなるでしょうか?本来ならば品物メモは買い物メモに属しているはずですが、その買い物メモは削除されているので参照できません。 このような状態は思わぬエラーを引き起こすことになります。これを避けるために、データベースに買い物メモと品物メモは親子関係にあることを明示する必要があります。外部キー制約を設定することで、親テーブルである買い物メモが削除された時に子テーブルである品物メモも削除されるようにします。 では、以上を踏まえて品物メモのテーブルを作成します。

// 買い物メモの個別アイテムを表すエンティティクラス
@Entity(
    // テーブル名を指定
    tableName = "shopping_memo_item_tbl",

    // 外部キー制約を定義
    foreignKeys = [
        ForeignKey(
            // 参照先のエンティティクラスを指定
            entity = ShoppingMemoEntity::class,
            // 参照先テーブルの主キーカラム
            parentColumns = ["shopping_memo_id"],
            // 参照元テーブルの外部キーカラム
            childColumns = ["shopping_memo_id"],
            // 参照先レコード削除時の動作:親レコードのIDが削除されたら消す。
            onDelete = ForeignKey.CASCADE,
            // 参照先レコード更新時の動作:親レコードのIDを更新するとエラーになる。
            onUpdate = ForeignKey.RESTRICT
        )
    ],

    // インデックスを定義
    indices = [
        // 買い物メモアイテムIDに対するユニークインデックス
        Index(value = ["shopping_memo_item_id"], unique = true),
        // 検索性能向上のための買い物メモIDへのインデックス
        Index(value = ["shopping_memo_id"])
    ]
)
data class ShoppingMemoItemEntity(
    // 主キー(自動採番)
    @PrimaryKey(autoGenerate = true)
    // カラム名を指定
    @ColumnInfo(name = "shopping_memo_item_id")
    val id: ShoppingMemoItemId = ShoppingMemoItemId(0),

    // 買い物メモへの外部キー
    @ColumnInfo(name = "shopping_memo_id")
    val memoId: ShoppingMemoId,

    // 買い物アイテムの名称
    @ColumnInfo(name = "shopping_memo_item_name")
    val name: String,

    // 買い物アイテムのメモ
    @ColumnInfo(name = "shopping_memo_item_memo")
    val memo: String,

    // 買い物アイテムのチェック状態
    // true: チェック済み, false: 未チェック
    @ColumnInfo(name = "shopping_memo_item_is_checked")
    val isChecked: Boolean
)

このクラスでは、@Entity アノテーションの foreignKeys パラメーターを用いて、買い物メモと品物メモ間における外部キー制約を定義しています。具体的には、ShoppingMemoItemEntity(品物メモ)が ShoppingMemoEntity(買い物メモ)の主キーカラム(shopping_memo_id)を外部キーとして持ち、以下のような制約や動作が設定されています。

  • entity = ShoppingMemoEntity::class

    参照先(親テーブル)のEntityを指定しています。

  • parentColumns = ["shopping_memo_id"] / childColumns = ["shopping_memo_id"]

    参照先で定義されている主キー(shopping_memo_id)と、子テーブル側(shopping_memo_item_tbl)の主キー(shopping_memo_id)を紐付けます。

  • onDelete = ForeignKey.CASCADE

    親テーブル(ShoppingMemoEntity)の該当レコードが削除された場合、それに紐付いている子テーブルのレコードもまとめて削除されるように指定しています。つまり買い物メモが削除されたら、そのメモに紐付く品物メモも同時に削除されます。

  • onUpdate = ForeignKey.RESTRICT

    親テーブル(ShoppingMemoEntity)の主キーが更新されたとき、例外を発生させて更新を防ぎます。ほとんどの場合、主キーを変更するような設計は避けるべきです。

さらにこのEntityにはインデックスが設定されています。インデックスとは、不要な全行スキャンを避けつつ目的のレコードのみを素早く特定できる仕組みです。 上記のレコードでは以下のような設定がされています。

  • Index(value = ["shopping_memo_item_id"], unique = true)

    shopping_memo_item_idに対するユニークインデックスを設定し、同じ値を重複して登録できないようにしています。これによりIDの一意性が保証されるだけでなく、検索時も指定したIDに該当する行を素早く特定可能になります。

  • Index(value = ["shopping_memo_id"])

    shopping_memo_idで検索するときの性能を向上するためのインデックスを設定しています。たとえば、特定の買い物メモに紐づくアイテムを一覧表示するときに、全行をスキャンせずインデックスから該当レコードを即座に参照できます。

インデックスはあくまで補助的な構造であり、必須ではありませんが、大規模データを扱う場合や特定の列でもっとも頻繁に検索するとわかっている場合には有効な手段になります。 一方で、不要な列にまでインデックスを付与すると、テーブルの保守やデータ更新時のコストが増大するので、使いどころを考える必要があります。

Entityの用意は以上となります。

DAOの用意

DAO(Data Access Object)とは、データベースの操作を集約したクラスです。このクラスを用意することでデータベースを扱う処理を集約できます。 さらに、データベースの操作には作成・読み取り・更新・削除がありますが、Roomでは特別なアノテーションが用意されており、SQL文を書かずともデータベースを操作するメソッドを簡単に定義できます。

ではまずDAOを作成します。 DAOを用意するには、インターフェイスに@Daoアノテーションを付与します。また、DAOはアプリに対して1つ作成する場合やテーブル毎に作成する場合などがありますが、今回はアプリに1つとします。

@Dao
interface ShoppingMemoDao {
    /**
     * 買い物メモの挿入
     */
    @Insert
    suspend fun insertMemo(memo: ShoppingMemoEntity): Long
}

上の例では、買い物メモの挿入(作成)を定義しています(戻り値は挿入したレコードのID)。本来ならばSQL文を書く必要がありますが、Roomの場合は簡潔に書くことができます。これは更新(@Update)、削除(@Delete)でも同様です。 しかし、場合によっては複雑な操作を定義する必要があります。その場合は@Queryアノテーションを使用して複雑な操作を定義します。たとえば買い物メモのタイトルを更新するメソッドを定義すると以下の様になります。

@Dao
interface ShoppingMemoDao {
    /**
     * 買い物メモの挿入
     */
    @Insert
    suspend fun insertMemo(memo: ShoppingMemoEntity): Long

    /**
     * 買い物メモのタイトル更新
     */
    @Query(
        "UPDATE shopping_memo_tbl SET shopping_memo_title = :newTitle WHERE shopping_memo_id = :id"
    )
    suspend fun updateTitle(id: ShoppingMemoId, newTitle: String)

}

以上のようにSQL文を定義できます(読み取りに関してはこのようにSQL文で定義するしかありません)。

DTOの用意

先ほど紹介したDAOのメソッドに加えて、読み取りのメソッドを追加してみます。レコードを読み取った後はその結果を返す必要があります。その結果にはEntityクラスを使うこともできますが、今回はDTOクラスと呼ばれるものを使用して、DTOクラスで結果を返すようにします。

DTO(Data Transfar Object)とは、異なるレイヤーにデータを転送する際に使用するオブジェクトです。DTOはゲッターおよびセッター以外のメソッドを持たず、メンバー変数のみを保持します。 たとえば、買い物メモのDTOは以下のようになります。

data class ShoppingMemoDto(
    val id: ShoppingMemoId = ShoppingMemoId(0),
    val title: String,
    val date: String?,
    val itemCount: Int,
    val checkedItemCount: Int = 0
)

買い物メモを取得するメソッドは、このクラスで結果を返すようにします。 また、データベースの操作の返り値はFlow(前章で説明しました)を使用します。 Flowを使用することで、データベースの値を監視してデータを最新の状態に保つことができます。

以上を踏まえて、買い物メモを取得するメソッドは以下のようになります。

    @Query(
        """
        SELECT 
            m.shopping_memo_id AS id,
            m.shopping_memo_title AS title,
            m.shopping_memo_date AS date,
             -- 各買い物メモに紐づく品物の総数をカウント
        COUNT(i.shopping_memo_item_id) as itemCount,
        -- チェック済み品物の数を集計(is_checked=1の場合のみカウント)
        SUM(CASE
            WHEN i.shopping_memo_item_is_checked = 1 THEN 1
            ELSE 0
        END) as checkedItemCount
        FROM shopping_memo_tbl m
        -- 買い物メモの品物テーブルを左外部結合
        LEFT JOIN shopping_memo_item_tbl i
        ON m.shopping_memo_id = i.shopping_memo_id
        WHERE m.shopping_memo_id = :id
    """
    )
    fun getShoppingMemoById(id: ShoppingMemoId): Flow<ShoppingMemoDto>

このメソッドを一度呼び出せば、指定の買い物メモの最新の状態を常に取得し続けることができます。

データベースインスタンスの用意

ここまで準備してきたDAOを扱うにはデータベースインスタンスと呼ばれるものを用意します。 データベースインスタンスはアプリのデータベースにアクセスするためのアクセスポイントとなり、DAOの取得・管理を行います。データベースインスタンスを作るにはRoomDatabaseを継承したクラスをつくり、そのクラスに@Databaseアノテーションを付与します。さらに、DAOクラスごとに、引数無しのDAOインスタンスを返り値に持つ抽象メソッドを宣言します。 では実際に作ってみます。

@Database(
    entities = [
        ShoppingMemoEntity::class,
        ShoppingMemoItemEntity::class
    ],
    version = 2,
    exportSchema = false
)
@TypeConverters(ShoppingMemoConverters::class)
abstract class AppDatabase : RoomDatabase() {

    // DAOの登録
    abstract fun getShoppingMemoDao(): ShoppingMemoDao

    companion object {
        private const val DATABASE_NAME = "shopping_memo_database"

        @Volatile
        private var Instance: AppDatabase? = null // null許容型で、nullとして宣言する。

        // データベースビルダーに必要なContextパラメータを持つメソッド
        fun getDataBase(context: Context): AppDatabase {
            return Instance ?: synchronized(this) {
                Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    DATABASE_NAME
                )
                    // マイグレーションが失敗したときの対応
                    .fallbackToDestructiveMigration()
                    .build()
                    .also { Instance = it }
            }
        }
    }
}

順に確認します。

  • @Database(...)

    • entities = [ShoppingMemoEntity::class, ShoppingMemoItemEntity::class]

      データベースが扱うEntityを指定します。

    • version = 2

      スキーマのバージョンです。スキーマを変更するたびにバージョンを増やす必要があります。

    • exportSchema = false

      スキーマのバージョン履歴をバックアップしないように設定しています。

  • @TypeConverters(ShoppingMemoConverters::class)

    Roomがそのままでは扱えない型を、SQLiteで扱える型へ変換するためのメソッド群を提供するクラスを登録するためのアノテーションです。これにより、データベースやDAOでShoppingMemoConverters内の変換処理が自動的に使われ、たとえばカスタムクラスやDateなどを問題なく保存・読み出しできるようになります。

    ShoppingMemoConverterの内容はこちら

      object ShoppingMemoConverters {
          @TypeConverter
          @JvmStatic
          fun toShoppingMemoId(value: Long): ShoppingMemoId {
              return ShoppingMemoId(value)
          }
    
          @TypeConverter
          @JvmStatic
          fun fromShoppingMemoId(id: ShoppingMemoId): Long {
              return id.value
          }
    
          @TypeConverter
          @JvmStatic
          fun toShoppingMemoDate(value: String?): ShoppingMemoDate? {
              return ShoppingMemoDate.parse(value)
          }
    
          @TypeConverter
          @JvmStatic
          fun fromShoppingMemoDate(date: ShoppingMemoDate?): String? {
              return ShoppingMemoDate.toDbValue(date)
          }
    
          @TypeConverter
          @JvmStatic
          fun toShoppingMemoItemId(value: Long): ShoppingMemoItemId {
              return ShoppingMemoItemId(value)
          }
    
          @TypeConverter
          @JvmStatic
          fun formShoppingMemoItemId(id: ShoppingMemoItemId): Long {
              return id.value
          }
      }
    

 

  • abstract fun shoppingMemoDao(): ShoppingMemoDao

    Roomでは、抽象メソッドとしてDAOを定義すると、ビルド時に生成されるコードがこのメソッドと結びついて、DAOインスタンスを取得できるようになります。 このメソッドを通じて、ShoppingMemoDaoの各メソッドを呼び出せるようになる仕組みを提供しています。

  • @Volatile private var Instance: AppDatabase? = null

    AppDatabaseのシングルトンインスタンスを保持します。これにより、データベースのインスタンスが一度だけ初期化され、安全に複数のスレッドで共有されます。

  • fun getDataBase(context: Context): AppDatabase

    データベースのシングルトンインスタンスを取得するためのメソッドで、このメソッドは複数のスレッドが同時に呼び出してもデータベースが一度だけ初期化されるようにし、Instancenullの場合、データベースを初期化し、データベースインスタンスを返します。

    • .fallbackToDestructiveMigration()

      マイグレーションが失敗した場合に、既存のデータを破棄して新しいバージョンのデータベースを再作成します。これにより、データベースのスキーマが変更された場合でもアプリがクラッシュせずに動作し続けるようにします。

    • .build()

      Roomデータベースビルダーにより、指定された設定に基づいてデータベースインスタンスを構築します。

    • .also { Instance = it }

      構築されたデータベースインスタンスを変数Instanceに代入し、その後でそのインスタンスを返します。

以上のコードはAndroid Developer公式のコードを参考にして書いたのですが、今回の実装ではHilt(最初の章で説明しました)を使用してDIコンテナを用意しているので、ここでの排他制御 ( @Volatilesynchronized ) は不要であるとのご指摘を先輩からいただきました。DIコンテナがアプリケーションのライフサイクルを通じて同じAppDatabaseインスタンスを提供するからです。 その点を踏まえて今回は以下のような実装にしました。

AppDatabaseModule.kt

// Hiltによる依存性注入の設定
@Module
@InstallIn(SingletonComponent::class)
class AppDatabaseModule {
    @Provides
    @Singleton
    fun provideDatabase(
        @ApplicationContext context: Context
    ): AppDatabase = AppDatabase.getDataBase(
        context
    )
}

AppDatabase.kt

@Database(
    entities = [
        ShoppingMemoEntity::class,
        ShoppingMemoItemEntity::class
    ],
    version = 2,
    exportSchema = false
)
@TypeConverters(ShoppingMemoConverters::class)
abstract class AppDatabase : RoomDatabase() {
    abstract fun shoppingMemoDao(): ShoppingMemoDao
    // インスタンスの管理はDIコンテナに任せているため不要になります

    companion object {
        private const val DATABASE_NAME = "shopping_memo_database"

        fun getDataBase(context: Context): AppDatabase {
            return Room.databaseBuilder(
                context.applicationContext,
                AppDatabase::class.java,
                DATABASE_NAME
            ).fallbackToDestructiveMigration()
            .build()

        }
    }
}

以上でデータベースインスタンスの用意ができました。

Repositoryの用意

ここまでで、データベースの操作を呼び出すことはできるようになりましたが、さらにRepositoryと呼ばれるものを用意します。 Repositoryとは、データの取得や保存を一括して管理するクラスであり、アプリのメイン機能(ビジネスロジック)とデータアクセス部分が分離され、コードの保守がしやすくなります。 アプリケーションにおけるRepositoryクラスの立ち位置は、以下の図のようになります。

graph LR;
  classDef class1 fill:#ffccdd
    View-->ViewModel;
    ViewModel-->UseCase;
    subgraph 必要に応じて作成
    UseCase
    end
    UseCase-->Repository:::class1;
    Repository-->DAO;

この図に示されているように、RepositoryクラスはUseCaseとDAOの間に位置し、UseCaseがビジネスロジックを処理し、Repositoryクラスを介してデータアクセスを管理します。 DAOは、前述したように具体的なデータベース操作を担当し、Repositoryクラスがデータの取得や保存のロジックを統一的に扱います。 このアプローチにより、データソースが変更された場合でも、Repositoryクラスを通じて一貫したインターフェイスを提供するため、アプリケーションの他の部分への影響を最小限に抑えることができます。(今回の実装ではUseCaseクラスは作成しませんでした。)

では実際にRepositoryクラスを作成してみます。

ShoppingMemoRepository.kt

class ShoppingMemoRepository @Inject constructor(
    // クラスの詰め替えをするコンバーターやDAOなどの依存性注入
    private val appDatabase: AppDatabase,
    // ...
) {
    private val shoppingMemoDao = appDatabase.getShoppingMemoDao()
    /**
     * 買い物メモを新規作成する。
     */
    suspend fun createNewShoppingMemo(): ShoppingMemoId {
        val defaultTitle = "無題"
        val shoppingMemoDate = null
        val shoppingMemoId = shoppingMemoDao.insertMemo(
            ShoppingMemoEntity(
                title = defaultTitle,
                date = shoppingMemoDate
            )
        )
        return converter.toShoppingMemoId(shoppingMemoId)
    }

    /**
     * 買い物メモのタイトルを更新する
     */
    suspend fun updateShoppingMemoTitle(
        id: ShoppingMemoId,
        newTitle: String
    ) = shoppingMemoDao.updateTitle(id, newTitle)

    // その他の操作が以下に続く
}

あとはViewModel等で呼び出して操作を行うことができます。

DetailViewModel.kt

@HiltViewModel
class DetailViewModel @Inject constructor(
    private val repository: ShoppingMemoRepository
) : ViewModel() {
      /**
     * 買い物メモのタイトルを更新する。
     */
    fun updateShoppingMemoTitle(newTitle: String) =
        viewModelScope.launch(Dispatchers.IO) {
            startLoading()
            runCatching {
                _detailUiState.value.memo?.let {
                    repository.updateShoppingMemoTitle(it.id, newTitle)
                }
            }.onSuccess {
                Timber.tag(LOG_TAG).d("買い物メモのタイトルを無事更新できました。")
            }.onFailure {
                Timber.tag(LOG_TAG)
                    .d("何らかの問題が発生して買い物メモのタイトルを更新できませんでした。")
                Timber.e(it)
            }
            endLoading()
        }.invokeOnCompletion {
            Timber.tag(LOG_TAG).d("買い物メモのタイトルを更新するコルーチンは終了しました。")
        }

        // 以下省略
}

ダークテーマ対応

最近のスマホにはダークテーマ(ダークモード)が標準で搭載されるようになりました。 ダークモードは、画面の背景色を白色から黒色または暗い色に変更する機能です。これにより、とくに夜間や暗い場所での視認性の向上や、バッテリー消耗を抑える効果があります。OLEDディスプレイを搭載したデバイスでは、黒いピクセルが表示されていないため、さらに電力が節約されます。

ダークテーマについては、何もしなくともある程度は対応できます。 Android StudioでJetpac Composeプロジェクトを作成すると、以下のファイルがサンプルとして作成されています。

Theme.kt

// サンプル(一部省略)
private val DarkColorScheme = darkColorScheme(
    primary = Purple80,
    secondary = PurpleGrey80,
    tertiary = Pink80
)

private val LightColorScheme = lightColorScheme(
    primary = Purple40,
    secondary = PurpleGrey40,
    tertiary = Pink40
)

@Composable
fun MyApplicationTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    // Dynamic color is available on Android 12+
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }

        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

アプリの配色に関しては、このTheme.ktファイルで管理されているようです。上記のコードを見てみると、上2つのprivate変数(DarkColorSchemeおよびLightColorScheme)でそれぞれライトテーマ、ダークテーマの設定が書かれており、 下のval colorScheme = when {..の部分でテーマの切替えが実装されています。

しかしながら、場合によっては文字が見にくくなったり、配色が原因でUIが判別しにくいものになることがあります。 こうしたことを避けるためには、このテーマ設定を独自に実装する必要があります。 本章ではその対応について記述します。また、内容は以下の公式ドキュメントに沿っています。

Jetpack Compose でのマテリアル テーマ設定 |  Android Developers

アプリのテーマを全て自分で指定することもできますが、以下のGoogleが提供しているMaterial Theme Builderを使用することで、簡単に独自の配色でテーマを作成できます。

Material Theme Builder

※公式サイトを開くと以下のようなページが開くと思います。(閲覧日:2025/01/29)

Material Theme Builder 公式サイト

配色を行うには画面左にある「Core colors」にある色を変更します。 色を変更するには、丸い色のアイコンをクリックします。色の指定には表示されるツールを使用する、もしくはカラーコードを入力する方法があります。 配色には上のPrimaryカラーから順に設定します。 それぞれの配色の役割は以下の公式サイトで説明されています。

Material 3 ガイドライン

配色が完了したら、設定をエクスポートします。 画面右上のプラスボタン(の様なもの)をクリックして、展開されたサイドバーの下にある「Export」をクリックします。 選択肢が出てくるので今回は「Jetpack Compose(Theme.kt)」を選択します。すると、Color.kt、Theme.kt、Tpye.ktの3つのファイルがダウンロードされるはずです。 後はそのファイルの内容をプロジェクトに適応させれば作成したアプリのテーマを適応させることができます。 このとき、ダークモードも準備されています。

たとえば、Color.ktファイルを開くと以下のようになっているはずです。

package com.example.compose
import androidx.compose.ui.graphics.Color

val primaryLight = Color(0xFF6D5E0F)
val onPrimaryLight = Color(0xFFFFFFFF)
val primaryContainerLight = Color(0xFFF8E287)
val onPrimaryContainerLight = Color(0xFF534600)
val secondaryLight = Color(0xFF665E40)
val onSecondaryLight = Color(0xFFFFFFFF)
val secondaryContainerLight = Color(0xFFEEE2BC)
val onSecondaryContainerLight = Color(0xFF4E472A)
// 以下、大量の配色設定が続く

これをプロジェクトにあるColor.ktファイルに追記します。 元からある色は削除します。

import androidx.compose.ui.graphics.Color

- val Purple80 = Color(0xFFD0BCFF)
- val PurpleGrey80 = Color(0xFFCCC2DC)
- val Pink80 = Color(0xFFEFB8C8)

// 以下を追加
+ val primaryLight = Color(0xFF6D5E0F)
+ val onPrimaryLight = Color(0xFFFFFFFF)
+ val primaryContainerLight = Color(0xFFF8E287)
+ val onPrimaryContainerLight = Color(0xFF534600)
+ val secondaryLight = Color(0xFF665E40)
+ val onSecondaryLight = Color(0xFFFFFFFF)
+ val secondaryContainerLight = Color(0xFFEEE2BC)
+ val onSecondaryContainerLight = Color(0xFF4E472A)
// 以下、大量の配色設定が続く

Theme.ktファイルなども同様に置き換えます。(ただしクラス名などが変わらないように注意します。)

Edge to Edge対応

Edge to Edgeとは、画面内に表示されているコンテンツを、システムのUI(たとえば、画面上のステータスバーなど)の背景にも表示させることを指します。 Android 15(SDK35)以降のバージョンではEdge to Edgeが強制されるため、対応が必要です。 以下のサイトにその対応について書かれています。

アプリでコンテンツをエッジ ツー エッジで表示し、Compose でウィンドウ インセットを処理する |  Android Developers

注意しなければならないのは、コンテンツの表示が広がったことで、そのコンテンツがその他のUIと干渉しないようにしなければならないということです。 たとえば今回のアプリでは、リストを使用しており以下のように実装しました。

LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .padding(vertical = 12.dp)
                .consumeWindowInsets(paddingValues), // この行を追加
            contentPadding = paddingValues, 
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Top
        ) {

            // 省略

            items(
                items = listUiState.shoppingMemoList,
                key = { memo -> memo.id.value }
            ) { memo ->
                ShoppingMemoListCell(
                    shoppingMemoSummary = memo,
                    onMemoCellClick = { onMemoCellClick(memo.id) }
                )
            }
            item {
                // 最後の要素がボタンに被らないように
                Spacer(
                    modifier = Modifier
                        .navigationBarsPadding()
                        .height(88.dp) // FABの高さ + パディング
                )
            }
        }

上記のコードでは、Modifier.consumeWindowInsets(paddingValues)を使用してウィンドウインセットを適切に管理することで、システムUIに重ならないようにコンテンツを配置しています。 また、リストの最後にはスペーサーを追加し、リストを一番下までスクロールした際に、下に配置しているボタンと被らないようにしています。

最後に

Jetpack Composeを用いたアプリ開発に挑戦し、その過程で得た学びを本記事にまとめました。 今回の実装を通じて、アプリケーションの開発には幅広い知識が求められることを改めて実感しました。 とくに、データベース設計の難しさを痛感し、この分野についてさらなる学習が必要であると感じています。 今後も継続して知識を深め、より良いアプリケーションを開発できるよう努力していきたいと思います。

Androidアプリケーション開発を経験して学んだこと#設計編

この記事について

こんにちは、Beta Computing株式会社でアルバイトをしていますAndroidアプリケーション開発担当です。

前回の「Androidアプリケーション開発を経験して学んだこと#フレームワーク&ライブラリ編 」に引き続き、学んだことを書いていきます。今回は設計(Kotlin)についてです。

学んだこと

Dataクラス

Dataクラスとは、その名の通りデータを持っているだけのクラスです。

Kotlinには「Kotlin data class」と呼ばれる仕組みがあり、以下の様にDataクラスを宣言することが出来ます。

data class User(
  val id: Long,
  val name: String,
  val age: Int,
  //...
)

普通のクラスと違うところは、equals()toString() といったメソッド を自動的に適切に実装してくれるところです。

KotlinのDataクラスについては以下のサイトが勉強になりました。

参照:クラスとデータクラスの違いを理解する(class, data class)


可視性について

可視性とは変数やメソッド、クラスなどがどこからアクセスできるかを示すものです。
もちろん私もJavaを勉強していた頃から「public」や「private」といった可視性を使い分けており、それぞれの意味を理解していましたが、Kotlinではその種類が異なるようです。
Javaでは修飾子を書かない場合は「パッケージプライベート」となり、そのパッケージ内でのみアクセス可能でありますが、Kotlinで修飾子を書かない場合は「パブリック」となります。
また、新たに「internal」修飾子が追加され、指定した場合はモジュール内からのみアクセス可能となります。
さらに気をつけたいのが、「protected」修飾子のアクセス可能範囲です。Javaではその修飾子がついたサブクラスと同じパッケージ内でのアクセスが可能でしたが、Kotlinではサブクラスのみアクセス可能となっています。

KotlinはJavaとの互換性があることから、可視性についても同じかと思っていたので、確認しておいて良かったです。

可視性については、以下のサイトが大変わかりやすく説明しています。

参照:Kotlin - 可視性


UnitやNothing型、Any型

Unit

Unitは、Javaのvoidに相当します。基本的には書く必要がありません。
以下の様に戻り値がないときに書くことは出来ますが、推奨はされないようです。

fun function : Unit {
  print("Hello, world")
  return
}

Nothing

Nothingは全てのクラスを継承しているので、どんな型も代入することが出来ません。
そのためインスタンスが存在することはありません。
私はこれを聞いて一体どこでNothingを使うのだろうかと思ったのですが...
以下のサイトでわかりやすく説明されていました。

参照:Kotlin 紹介 - Nothing 型の使い道

TODO関数はNothingを返しています。全てのクラスを継承しているのでどのような型を返すメソッドに実装してもコンパイルエラーになりません。

fun function : String {
  TODO("後でやります。")
}

Any

Anyは全てのクラスが継承しているクラスです。
JavaのObjectクラスと同じような立ち位置のクラスです。

Anyについては以下のサイトが大変勉強になりました。

参照:JavaプログラマがKotlinでつまづきがちなところ


メソッドの命名

今まで、メソッドの命名は経験からなんとなくで命名してきましたが、やはりこちらにもルールはあり、それについてのアドバイスを先輩社員の方からいただきました。

例えば、商品の情報を検索し取得するメソッドに以下のような命名をしていました。

/**
 * 商品の情報を検索し取得します
 * 通信処理はIOスレッドで実行されます
 **/
suspend fun productInfoBackgroundReceiver()

特別な事情が無い限り、「動詞+目的語」という命名が適しているとのご指摘をいただき、

/**
 * 商品の情報を検索し取得します
 * 通信処理はIOスレッドで実行されます
 **/
suspend fun searchProduct()

以上の様に変更しました。
このメソッドはsuspend関数であるため、非同期処理が行われることが明確なため、「Background」を省略出来るようです。 また、「Receiver」は名詞であり、「productInfoBackgroundReceiver()」は「名詞()」となり、メソッドとしての命名にふさわしくないとのことでした。

メソッドの命名には言語だったり、会社によってルールがあったりしますが、Kotlinでは以下のサイトが大変勉強になりました。

参照:クラス・メソッド命名早見表


HTTPリクエスト時のクエリパラメタのエスケープについて

今回開発したアプリケーションでは、ユーザーが文字列を入力してサーバーに問い合わせるといった機能が搭載されていました。  

  val client = HttpClient(CIO)
  val queryParamVariable = "アイテム名"
  val url = "https://example.com/api/hoge?q=$queryParamVariable
  ...

以上のコードでは、外部から受け取った内容をクエリの内容として変数urlに埋め込んでサーバーに問い合わせる流れになりますが、urlをそのまま使用してしまっているので、パラメータの指定が上手くいかない場合があります。

それを防ぐためにもエンコードを行う必要があります。

例えば、Ktorを使用してHttp通信を行う場合、以下の様に書きます。

  val request = HttpRequestBuilder(
    scheme = "https", 
    host = "ホスト名", 
    path = "パス") {
              parameters.append("q", query) // クエリ内容
          }
    _client.get(request)

以上のように書くことで、エンコードが行われます。

この様な対策をすることは、今後アプリケーションの安全性を保つ上で非常に重要であると感じました。

エンコードついては、以下のサイトが大変わかりやすく解説しています。

参照:Making requests


Object(シングルトン)

シングルトンとは、インスタンスがひとつしか生成できないクラスのことです。
kotlinではシングルトンを作成する際に、オブジェクト宣言と呼ばれる手法で定義します。

例えば、以下の様に定義出来ます。

object Single {
  val name = "名前"
  ...
}

Javaでは自分でクラスをシングルトンになるように考えて実装しなければならなかったので、Kotlinでは簡潔に実装できるように感じました。

オブジェクト宣言については以下のサイトが大変勉強になりました。

参照:[Kotlin]オブジェクト宣言 – Kotlin流シングルトンの実装


ViewModel&Data Binding

ViewModelとは、MVVM(Model-View-ViewModel)と呼ばれる設計パターンに出てくる概念です。
MVVMではソフトウェアをModel層、View層、ViewModel層の三つに分割し、それぞれに役割を持たせます。
簡単に紹介すると以下の様な役割分担を行います。

Model

ソフトウェアの具体的なビジネスロジックを担います。 主に計算や通信、データ保持などを任せます。

View

UIの定義およびUIの出力を担います。ユーザーインターフェースに関連する処理やユーザからの入力、ユーザーへの出力などを任せます。

ViewModel

ViewとModelとの間で、情報を伝達する役割を担います。情報の伝達には後述するData Bindingと呼ばれるしくみを利用することがあります。

以上の3つの層にソフトウェアを分けて設計することで、ソフトウェアの保守性および開発生産性を向上させることが出来ます。

このアーキテクチャをAndroidアプリケーションで使用するにあたり、Data Bindingと呼ばれるしくみを利用します。
Data BindingはAndroid公式で「プログラムではなく宣言形式を使用して、レイアウト内のUIコンポーネントをアプリのデータソースにバインドできるサポートライブラリです。」と説明されています。(以下、参照元)

参照:データ バインディング ライブラリ

簡単に言うと、変更されたデータをコードからView側へ伝えるためのライブラリです。 今回の開発は、このしくみを用いてViewModelで変更された内容をViewに伝えたり、その逆を行ったりしました。

MVVMアーキテクチャを実装する際に、一番困ったことはData Bindingに関する実装でした。そもそものしくみを理解することも時間がかかりましたし、使えるように準備をすることにも多くの時間を費やしてしまいました。

そこでData Bindingを使うまでの流れを本記事では紹介しようと思います。

まず、Data Bindingを有効にするための設定が必要です。 モジュールのbuild.gradleに以下の様な記述を追加します。

android {
    ...
    buildFeatures {
        dataBinding true
    }
}

この記述に関してはウェブ上に様々なバージョンにおける記述が入り交じっており、なかなか上手く設定することが出来ませんでした。 私の環境では以上の記述で動作することが確認出来ました。

また、Data Bindingを有効にしても、レイアウトファイルのルートタグをlayoutに変更しないと、Data Bindingを使うことが出来ません(私はこれをよく忘れてしまっていました)。
例えば以下の様なタグがルートタグに設定されていると上手くいきません。

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    ...

正しくは以下の様にします。

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    ...

DiffUtil

DiffUitlはRecyclerViewを実装する際に使用したユーティリティクラスです。
DiffUtilを用いることでRecyclerViewのリスト更新時に、変更のあった差分のみ更新されるため、 リストを効率良く更新することが出来ます。

今回の開発では、以下の様なクラスを作成しました。
それぞれのメソッドや変数の役割はドキュメントコメントの通りです。
※ リストの要素であるItemクラスは以下の様なデータクラスです。

class Item(val id: String, val title: String, val authorName: String) 
internal class ItemsDiffCallBack : DiffUtil.Callback() {
    /**
     * 変更前のアイテムリスト
     */
    var oldList: List<Item> = emptyList()

    /**
     * 変更後のアイテムリスト
     */
    var newList: List<Item> = emptyList()

    /**
     * 変更前のリストのサイズを返す
     */
    override fun getOldListSize(): Int {
        return this.oldList.size
    }

    /**
     * 変更後のリストのサイズを返す
     */
    override fun getNewListSize(): Int {
        return this.newList.size
    }

    /**
     * 2つのアイテム自体が同じものか判定
     */
    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        val oldItem: Item = this.oldList[oldItemPosition]
        val newItem: Item = this.newList[newItemPosition]

        // ここでは識別子が同じかどうかで判定
        return oldItem.id == newItem.id
    }

    /**
     * 2つのアイテムの内容が同じものか判定
     */
    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        val oldItem: Item = this.oldList[oldItemPosition]
        val newItem: Item = this.newList[newItemPosition]

        // ここでは要素が全て同じかで判定
        if (oldItem.id != newItem.id) return false
        if (oldItem.title != newItem.title) return false
        if (oldItem.authorName != newItem.authorName) return false
        return true
    }
}

これはDiffUtil.Callbackクラスを継承したクラスです。Callbackクラスは更新前と更新後のリストを比較し、差分を計算するためのクラスです。
差分を計算するには上記の2つリストと4つのメソッドを実装する必要があります。

次にこのクラスを用いて実際に差分を計算させ、変更のあった要素だけ更新させるように実装してみます。
※ 今回はリストにはRecyclerView、設計パターンにはMVVMを使用しています。

class MainActivity : AppCompatActivity() {
    private val viewModel: MainActivityViewModel by viewModels()
    private val diffCallBack = ItemsDiffCallBack()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // View等の設定は省略

        // アイテムリストにアダプタを設定する
        val adapter = RepositoryAdapter(this::onRepositoryClicked)
        binding.recyclerview.layoutManager = LinearLayoutManager(this)
        binding.recyclerview.adapter = adapter

        // アイテムリストの変更を購読し、変更をRecyclerViewに反映させる
        viewModel.items.observe(this) { newList ->
            val oldList: List<Item> = adapter.items
            this.diffCallBack.oldList = oldList
            this.diffCallBack.newList = newList

            // 変更差分を計算する
            //  (この例ではUIスレッドでの差分計算を行っていますが、必要に応じてIOスレッドで差分計算を行ったり、AsyncListDifferを利用したりします。)
            val diffResult: DiffUtil.DiffResult = DiffUtil.calculateDiff(this.diffCallBack)

            // RecyclerViewに反映させる
            adapter.items = newList
            diffResult.dispatchUpdatesTo(adapter)
        }
    }
}

以上の様に実装することでリストの差分を計算し、差分のみ変更することでリストを効率良く更新することが出来ます。

DiffUtilの実装については、以下のサイトが大変わかりやすく説明しています。

参照:DiffUtilを非同期に使いRecyclerViewを更新する


Androidの権限

Androidにはパーミッション(権限)があります。パーミッションは通信や位置情報の利用の権限をアプリケーションに与えるためのものです。
Androidアプリケーションには必要なパーミッションを必要な時に与えます。 今回の開発では、ネットワークに接続する必要があったのでネットワークに関するパーミッションを与えました。
パーミッションを与えるにはAndroidManifest.xmlファイルに以下の様に記述します。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <!-- インターネット接続の許可 -->
    <uses-permission android:name="android.permission.INTERNET" />

    ...

これでアプリケーションがネットワークに接続することが出来ます。
その他のパーミッションについては以下のサイトが大変わかりやすく紹介しています。

参照:【Androidアプリ開発】パーミッション(権限)の概要と一覧


Coroutine

Coroutine(コルーチン)とは、Androidで使用することが出来る並列実行のデザインパターンとAndroid公式で紹介されています。

参照:Android での Kotlin コルーチン

コルーチンの仕組みを利用することで、以下の恩恵を受けることが出来ます。

  1. スレッドをブロックせずに処理を一時停止・再開可能
  2. 非同期処理を同期処理の様に記述可能
  3. 機動のオーバヘット・メモリーが少ない。

※ 以下のサイトから参照しました。

参照:Kotlin:コルーチン(Coroutine)

コルーチンではsuspend関数と呼ばれるものを使用します。
例えば以下の様に記述します。

suspend fun callApi(query: String, option: RepositorySortOption): List<Repository> {
        return withContext(Dispatchers.IO) {
          ...

suspend関数はスレッドをブロックせずに処理を一時停止・再開出来る機能を持っています。
この機能を使って非同期処理をまるで同期処理のようにシンプルに記述することが出来ます。

suspend関数の詳しい内容については、以下のサイトが大変わかりやすく説明されていました。

参照:Coroutine:Suspend関数とその仕組み

まとめ

今回はAndroidアプリケーションの開発を通して、学んだことのうち設計に関するものを集めました。特にコルーチンやMVVMは内容を理解するのに時間がかかりました。未だに理解していない部分も多いと感じるので、使いこなせるようにさらに学習を進めようと思います。

Jetpack Composeの学習記録

こんにちは、Beta Computing株式会社で学生アルバイトをしていますAndroidアプリケーション開発担当です。
来年4月から正式に入社予定ですので、現在は研修期間としてAndroid開発の勉強を進めています。
今回はAndroid開発のツールキットであるJetpack Composeについて学習した内容を当記事にまとめたいと思います。

Jetpack Composeの公式サイトはこちらになります。 Jetpack Compose UI App Development Toolkit - Android Developers

Jetpack Composeとは?

Jetpack Composeとは、AndroidのネイティブUIを構築するためのツールキットです。
公式サイトでは、従来の方法に比べて簡素化されており、少ないコードでなおかつ高速に動作すると紹介されています。
この章では、従来のXMLベースのレイアウト記法を復習してから、Jetpack Composeの特徴を見ていこうと思います。

従来の方法(XML)

従来のUI構築にはXMLベースのレイアウト記法が採用されていました。 XMLベースのレイアウト記法の基本的な仕組みは、UIの構造をXMLファイルで定義し、それをアプリケーションのコードから読み込んで使用する方式です。
開発者はres/layoutディレクトリ内にXMLファイルを作成し、その中でViewやViewGroupを階層的に記述していきます。
各要素には属性を設定し、IDや幅、高さ、マージンなどの属性を指定します。

これらのXMLファイルは、ActivityやFragmentのKotlinまたはJavaコードからsetContentView()メソッドやinflateメソッドを使って読み込まれます。
読み込まれたレイアウトの個々の要素は、findViewById()などのメソッドを使ってコードから参照され、操作することができます。 この方法では、UIの構造とアプリケーションのロジックが分離されており、それぞれを独立して管理できるのが特徴です。
また、Android Studioのレイアウトエディタを使用すれば、XMLを直接編集せずに視覚的にUIを構築することも可能です。さらに、データバインディングやビューバインディングなどの技術を使用することで、XMLとコードの連携をより効率的に行うこともできます。

簡単な例を以下に示します。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="ログイン"
        android:textSize="24sp"
        android:layout_gravity="center_horizontal"
        android:layout_marginBottom="32dp" />

    <EditText
        android:id="@+id/username_input"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="ユーザー名"
        android:inputType="text"
        android:layout_marginBottom="16dp" />

    <EditText
        android:id="@+id/password_input"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="パスワード"
        android:inputType="textPassword"
        android:layout_marginBottom="24dp" />

    <Button
        android:id="@+id/login_button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="ログイン" />

</LinearLayout>

この画面は以下のように表示されます。

画面の説明をします。 最上位の要素は垂直方向のLinearLayoutで、画面全体のコンテナとして機能しています。その中に、順に「ログイン」というテキストを表示するTextView、ユーザー名入力用のEditText、パスワード入力用のEditText、そして「ログイン」ボタンとしてのButtonが配置されています。EditTextには入力のヒントが設定され、パスワード用のものは入力内容が隠れるよう指定されています。

Jetpack Compose

XMLベースのレイアウト記法では、画面のデザインとプログラムを別々に書く必要がありましたが、 Jetpack Composeを使うと、すべてをKotlinというプログラミング言語で一緒に書くことができます。
Jetpack Composeでは、ボタンやテキスト、画像などの画面の部品を「関数」として作ります。これらの関数を組み合わせて、アプリの画面全体を作り上げていきます。
また、Composeは画面の変化を自動的に反映してくれます。例えば、ユーザーが何かを入力したり、ボタンを押したりしたときに、関連する部分だけが自動的に更新されます。
これにより、画面の更新について細かく指示を書く必要がなくなり、より簡単にアプリを作ることができます。

では、先ほどの画面をJetpack Composeを使って書き直してみます。

import androidx.compose.foundation.layout.Arrangement
// import文省略

@Preview(showBackground = true)
@Composable
fun LoginScreen() {
    var username by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "ログイン",
            fontSize = 24.sp,
            modifier = Modifier.padding(bottom = 32.dp)
        )

        OutlinedTextField(
            value = username,
            onValueChange = { username = it },
            label = { Text("ユーザー名") },
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 16.dp)
        )

        OutlinedTextField(
            value = password,
            onValueChange = { password = it },
            label = { Text("パスワード") },
            visualTransformation = PasswordVisualTransformation(),
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 24.dp)
        )

        Button(
            onClick = { /* ログイン処理 */ },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("ログイン")
        }
    }
}

この画面は以下のように表示されます。

先ほどの画面と同じ構成ですが、Jetpack Compose用にもう一度説明します。最上位の要素はColumnコンポーザブルで、画面全体のコンテナとして機能しています。その中に、順に「ログイン」というテキストを表示するText、ユーザー名入力用のOutlinedTextField、パスワード入力用のOutlinedTextField、そして「ログイン」ボタンとしてのButtonが配置されています。OutlinedTextFieldにはラベルが設定され、パスワード用のものは入力内容が隠れるよう指定されています。 このComposeレイアウトは、Kotlinコードとして記述され、Android開発環境で解釈されて実際の画面表示に変換されます。各要素の状態は変数として定義されており、これによってアプリケーションのコードから要素の状態を管理し、操作することが可能になっています。

Jetpack ComposeとXMLの違い

先ほど紹介した二つの記法は主に以下の様な違いがあります。

XML Jetpack Compose
アプローチ 命令型 宣言型
言語 マークアップ言語 Kotlin
UI構築 レイアウトを別のファイルで定義 コードで直接定義
反応性 手動での状態管理 状態に基づく自動更新

Jetpack Composeを採用するメリット

それでは、従来のXMLベースのレイアウト記法に代わって新たにJetpack Composeを導入するメリットを紹介します。 まずXMLベースのレイアウト機能には以下のようなデメリットがあります。

  • コードが冗長になりがち。
    • UIコンポーネントを詳細に定義する必要があるため。
  • 動的UIの管理が複雑。
    • 手動での状態管理やイベントハンドリングが必要であるため。
  • パフォーマンスの問題がある。
    • レイアウトが複雑になるとXMLの解析と描画に時間がかかるようになり、パフォーマンスに影響が出る可能性があります。
  • 編集が難しい。
    • レイアウトが複雑になると、直感的な編集が難しくなる場合があります。

これに対してJetpack Composeには以下のようなメリットがあります。

  • コードが簡潔である。
    • UIコンポーネントをKotlin関数として作成できるため、ボイラープレートコード(変更されることがなく、多くの箇所で書かれているのにも関わらず、プログラミング言語の仕様上省略が不可能なコード)を大幅に削減可能です。
    • 宣言的なアプローチにより、UIの構造がコードの構造と直接対応するため、可読性が向上します。
  • 状態管理が比較的簡単にできる。
    • 状態管理の仕組みが組み込まれているので、専用の関数で状態を管理できます。
  • パフォーマンスの向上が見込める。
    • 効率的なレンダリングエンジンを使用しており、必要な部分のみを再描画することができます。
    • レイアウトの階層が浅くなるため、複雑なUIでもパフォーマンスが向上します。
  • 編集が直感的にできる。
    • リアルタイムプレビュー機能により、コードの変更をすぐに視覚的に確認できます。
    • コンポーネントの再利用が容易になり、複雑なUIでも管理しやすくなります。

以上のような点から、Jetpack Composeを導入することを検討する開発者も増えています。 ただし、XMLベースのレイアウト記法にもメリットはあり、必要に応じて使い分ける必要があります。 例えば古いAndroidバージョンを使用している端末ではJetpack Composeが動作しなかったり、XMLベースの記法の方がライブラリやリソースが豊富だったりします。

Jetpack Composeの導入方法

Android Studioを使用していることを前提条件とします。 最新のAndroid Studioを利用していれば、新しいプロジェクトを作成するだけでJetpack Composeに対応したプロジェクトを作成できるようです。

もし既存のプロジェクトに追加する場合は、アプリのbuild.gradleファイルに次の定義を追加することで対応できます。

android {
    buildFeatures {
        compose true
    }
}

詳しくは公式サイトをご覧ください。
クイック スタート  |  Jetpack Compose  |  Android Developers

Jetpack Composeの使い方

それでは、Jetpack Composeの使い方について紹介します。 本章の内容は以下のサイトから学習した内容を記述しています。
Jetpack Compose を使ってみる  |  Android Developers

コンポーズ可能な関数

UIの記述にはコンポーズ可能な関数を使用します。コンポーズ可能な関数とは以下の特徴を持ちます。

  • @Composableアノテーションを持つ。
  • 他のコンポーズ可能な関数を呼び出せる。
  • 値を返さない。

この関数はUIの内容などを記述するために使用されます 。たとえば以下のような関数です。

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name!")
}

この関数は画面に「Hello (引数から受け取った名前)!」を表示します。 このような関数を組み合わせて画面を構築していきます。

コンポーネント

コンポーズ可能な関数によって作られる再利用可能なUI部品をコンポーネントと呼びます。コンポーネントには以下のような特徴があります。

  • 独立した機能単位として動作する。
  • 他のコンポーネントと組み合わせて使用できる。
  • データの入力を受け取りUIとして出力する。

コンポーズ可能な関数はコンポーネントを作るための手段であり、コンポーネントはコンポーズ可能な関数によって作られるものです。 つまり、先ほどのGreeting関数もコンポーネントとして機能します。これは単純なコンポーネントでしたが、より複雑なコンポーネントも作成できます。例えば以下のようなものです。

@Composable
fun UserCard(
    name: String,
    age: Int,
    onButtonClick: () -> Unit
) {
    Card(
        modifier = Modifier.padding(32.dp)
    ) {
        Column {
            Text(
                text = name,
                modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
            )
            Text(
                text = "Age: $age",
                modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
            )
            Button(
                onClick = onButtonClick,
                modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
            ) {
                Text("詳細を見る")
            }
        }
    }
}

このコンポーネントは以下のように表示されます。

このように、コンポーネントは様々な粒度で作成することが可能です。

Scaffoldの使い方

最上位のコンポーネントには「Scaffold」と呼ばれるものを使用します。(場合によってはカスタムレイアウトなどを目的に応じて使用することもあります。) Scaffoldを使用することで、Androidアプリの標準的なレイアウトパターンを実現したりすることや、マテリアルデザインを自動的に適用したりすることができます。

それでは試しに使ってみます。 Androidアプリのプロジェクトを作成して、「MainActivity.kt」ファイルに以下のコードを書きます。

// import文は省略

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MainScreen()
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("シンプルな画面") }
            )
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            Text("Hello, Jetpack Compose!")
        }
    }
}

@Preview(showBackground = true)
@Composable
fun MainScreenPreview() {
    MainScreen()
}

以上のコードを実行すると以下の様な画面が表示されます。非常にシンプルな画面です。

では、先ほどのコードのそれぞれの役割について見てみようと思います。 まずは以下の部分です。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MainScreen()
        }
    }
}

ここではアプリ起動時に表示される画面を定義しています。今回は次に定義するMainScreenを起動時の画面として設定しています。 では次にMainScreenの定義を見てみましょう。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("シンプルな画面") }
            )
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            Text("Hello, Jetpack Compose!")
        }
    }
}

ここでScaffoldが登場しています。Scaffoldには以下の様なパラメータを持っています。

  • topBar:ツールバーを指定する。
  • bottomBar:ボトムナビゲーションバーを指定する。
  • floatingActionButton:右下のボタン
  • …など他多数

詳しくは以下から確認することができます。
Jetpack Compose  |  Android Developers

今回はtopBarに「シンプルな画面」と表示するように指定しています。しかしよく見てみると、ScaffoldはtopBarのみを指定して、その後に以下の様に続けています。

Scaffold(
        topBar = {
            // ...内容...
        }
    ) { paddingValues -> // 急にラムダ式のような記述が始まる
        Column(
            modifier = Modifier
            // ...内容...
        )
    }

これはKotlinにおける特有?の書き方で、関数の最後の引数が関数型であれば以下のように書けるようです。

fun function(lambda: () -> Int) : Int { // 関数型を受け取る関数
    return lambda()
}

val result : Int = function { 1 } // 括弧でパラメータを指定
println(result) // 1と表示される

このような書き方を「トレーリングラムダ記法」と呼ぶようです。こちらの仕様については以下のサイトが大変勉強になりました。
Kotlin の trailing lambda は constructor にも使える話 - Qiita

さてここでScaffoldの実装を見てみると…

@Composable
fun Scaffold(
    modifier: Modifier = Modifier,
    topBar: @Composable () -> Unit = {},
    bottomBar: @Composable () -> Unit = {},
    snackbarHost: @Composable () -> Unit = {},
    floatingActionButton: @Composable () -> Unit = {},
    floatingActionButtonPosition: FabPosition = FabPosition.End,
    containerColor: Color = MaterialTheme.colorScheme.background,
    contentColor: Color = contentColorFor(containerColor),
    contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
    content: @Composable (PaddingValues) -> Unit
) {
// ... 以下実装
}

最後に「content: @Composable (PaddingValues) -> Unit」という引数を受け取っています。
詳しい仕様は省略しますが、この引数に画面のメインコンテンツを定義すれば良いようです。
ただし、必ずpaddingValuesを使用して適切な余白を確保する必要があるようです。

paddingValuesの仕様については以下のサイトが大変勉強になりました。
Jetpack Compose 1.2.0 では Scaffold の content に PaddingValues を必ず設定する - Infinito Nirone 7

Columnの使い方

次に、表示されるコンテンツの中身を見ていきましょう。

Column(
        modifier = Modifier
              .fillMaxSize()
              .padding(paddingValues),
          horizontalAlignment = Alignment.CenterHorizontally,
          verticalArrangement = Arrangement.Center
      ) {
          Text("Hello, Jetpack Compose!")
      }

ColumnはUI要素を縦方向に並べるための基本的なレイアウトコンポーネントです。横方向に並べるにはRowを使用します。
並べる数を増やすには以下の様に要素を追加するだけです。

Column(
        modifier = Modifier
              .fillMaxSize()
              .padding(paddingValues),
          horizontalAlignment = Alignment.CenterHorizontally,
          verticalArrangement = Arrangement.Center
      ) {
          Text("Hello, Jetpack Compose!")
          Text("Hello, Jetpack Compose!")
          Text("Hello, Jetpack Compose!")
      }

また、modifierを使用してサイズや配置を決定することができます。 modifierは多くのコンポーネントに使用できます。例えば先ほどのColumnにはfillMaxSize()が指定されています。
これはColumnが利用可能な画面スペースの最大サイズまで広がるように設定しています。
さらにpadding(paddingValues)によってScaffoldから提供されているpaddingValuesを用いて適切に余白を確保しています。 これによって他のUI要素と干渉することを防いでいます。

modifierには他にもサイズや配置を指定する方法があります。詳しくは以下のサイトが大変勉強になりました。
Jetpack Compose Modifier(修飾) - Qiita
Compose 修飾子  |  Jetpack Compose  |  Android Developers

また、modifier意外にも以下の二つのパラメータを使用してレイアウトを設定しています 。

  • horizontalAlignment = Alignment.CenterHorizontally
    • Column内の要素を横方向の中央に配置するための設定
  • verticalArrangement = Arrangement.Center
    • Column内の要素を縦方向の中脳に配置するための設定

Buttonの使い方

次は、画面にボタンを設置してみます。ボタンも同じくコンポーネントとして配置することができます。 例えば以下のようなコードを書いてみます。

@Composable
fun StyledButton() {
    Button(
        onClick = { /* クリックしたときの処理 */ },
        colors = ButtonDefaults.buttonColors(
            containerColor = Color.DarkGray
        ),
        modifier = Modifier.padding(16.dp)
    ) {
        Text(
            text = "スタイル付きボタン",
            color = Color.White
        )
    }
}

これは以下のように表示されます。

ボタンの背景は、Buttonの引数の「colors」でダークグレーを指定しています。
また、ボタンに表示される文字列はScaffoldと同じようにトレーリングラムダ記法で記述しています。

ボタンを押した時の処理は「onClick」の引数に渡します。 例えば、ボタンを押した際にダイアログを出現させるには以下の様に書きます。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            CenteredButton()
        }
    }
}

@Composable
fun CenteredButton() {
    val context: Context = LocalContext.current;
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Button(
            onClick = {

                Toast.makeText(context, "ボタンが押されました!", Toast.LENGTH_SHORT).show()
            }
        ) {
            Text("ここを押してください")
        }
    }
}

@Preview(showBackground = true)
@Composable
fun CenteredButtonPreview() {
    CenteredButton()
}

このように記述して画面中央のボタンを押すと、下から「ボタンが押されました!」と表示されます。 (これをトーストと呼びます。)

キーボードによる入力

キーボードによる入力もやってみます。まずは以下の様なコードを書きます。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            VerySimpleInputFieldPreview()
        }
    }
}

@Composable
fun VerySimpleInputField() {
    var text by remember { mutableStateOf("") }

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        TextField(value = text, onValueChange = { text = it }, label = { Text("入力してください") })
    }
    
}

@Preview(showBackground = true)
@Composable
fun VerySimpleInputFieldPreview() {
    VerySimpleInputField()
}

これは以下のように表示されます。

入力欄をタップしてキーボードを入力することで文字を入力することができます。

さて、ここで以下のコードに着目してみます 。

 var text by remember { mutableStateOf("") }

(私にとっては)見慣れない構文が出てきました。まず「by」は委譲を行うための構文です。
変数「text」は別のクラスのメソッドから初期化されています。これを委譲プロパティと呼びます。

詳しくは以下のサイトが大変勉強になりました。
Kotlin Delegate Property について調べてみた - Qiita

次に気になるのは「remember」と「mutableStateOf」です。こちらについては次の章で取り扱います。
ちなみに先ほどのコードがないと(正確にはonValueChange = { text = it }のようにonValueChangeの指定が無いと)キーボードから文字列を入力できません。

状態の更新

状態とは、ユーザーの操作や時間によって変化する値を指します。
先ほどのキーボードによる入力も状態によって管理されています。
状態の更新について説明する前に、コンポーザブルのライフサイクルと再コンポーズについて説明します。

コンポーザブルのライフサイクル

コンポーザブル(@Composableアノテーションが付与された関数のこと)のライフサイクルについて説明します。
コンポーザブルが作成されると、コンポジションと呼ばれるものに配置されます。コンポーザブルはコンポジションによってツリー構造で関係性を管理されています。

詳しくは公式サイトで説明されています。
コンポーザブルのライフサイクル  |  Jetpack Compose  |  Android Developers

このコンポジション内でコンポーザブルが作成された後は、状態と呼ばれるものが更新されることで、0回以上再コンポーズが行われます。
再コンポーズとは、状態の変化によってコンポーザブルを再構築する工程のことです。
そして画面が切り替わったりUI要素が削除された時、対応しているコンポーザブルはコンポジションから退場します。

再コンポーズ

再コンポーズではコンポーザブルが再構築されますが、コンポジション内のコンポーザブル全てが再構築されるわけではありません。

例えば以下のコードを例に考えてみます。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            RecomposeDemo()
        }
    }
}

@Composable
fun RecomposeDemo() {
    Log.d("Recompose", "RecomposeDemo composing")

    Column(modifier = Modifier.padding(16.dp)) {
        // トップレベルコンポーネント
        TopLevel {
            // 中間レベルコンポーネント
            MiddleLevel {
                // 最下層コンポーネント
                BottomLevel()
            }
        }
    }
}

@Composable
private fun TopLevel(
    content: @Composable () -> Unit
) {
    Log.d("Recompose", "TopLevel composing")

    var topCount by remember { mutableIntStateOf(0) }

    Column {
        Text(
            text = "Top Level (count: $topCount)",
            modifier = Modifier.padding(bottom = 8.dp)
        )

        Button(
            onClick = { topCount++ },
            modifier = Modifier.padding(bottom = 16.dp)
        ) {
            Text("Update Top")
        }

        content()
    }
}

@Composable
private fun MiddleLevel(
    content: @Composable () -> Unit
) {
    Log.d("Recompose", "MiddleLevel composing")

    var middleCount by remember { mutableIntStateOf(0) }

    Column(modifier = Modifier.padding(start = 16.dp)) {
        Text(
            text = "Middle Level (count: $middleCount)",
            modifier = Modifier.padding(bottom = 8.dp)
        )

        Button(
            onClick = { middleCount++ },
            modifier = Modifier.padding(bottom = 16.dp)
        ) {
            Text("Update Middle")
        }

        content()
    }
}

@Composable
private fun BottomLevel() {
    Log.d("Recompose", "BottomLevel composing")

    var bottomCount by remember { mutableIntStateOf(0) }

    Column(modifier = Modifier.padding(start = 32.dp)) {
        Text(
            text = "Bottom Level (count: $bottomCount)",
            modifier = Modifier.padding(bottom = 8.dp)
        )

        Button(
            onClick = { bottomCount++ }
        ) {
            Text("Update Bottom")
        }
    }
}

コンポジション内の構造は以下の通りです。

RecomposeDemo
└── Column
    └── TopLevel
        ├── Text ("Top Level (count: X)")
        ├── Button ("Update Top")
        └── MiddleLevel
            ├── Text ("Middle Level (count: Y)")
            ├── Button ("Update Middle")
            └── BottomLevel
                ├── Text ("Bottom Level (count: Z)")
                └── Button ("Update Bottom")

このコードは大きく分けて三層構造になっています。上から最上層、中間層、最下層があり、それぞれの層にボタンとボタンを押した回数を表示するテキストが設置されています。 また、それぞれの層に以下のようなコードが配置されています。

Log.d("Recompose", "MiddleLevel composing")

これはログを出力するコードです。再コンポーズが行われると必ずこのコードによってログが出力されます。
例えば最上位のコンポーザブルが再コンポーズされると以下のように出力されます。

2024-11-10 10:13:34.375 14056-14056 Recompose  com.example.myapplication  D  TopLevel composing

さらにそれぞれの層にはカウンターを状態として持っています。詳しくは次の章で取り扱います。

var middleCount by remember { mutableStateOf(0) }

この状態はそれぞれのボタンが押されることでインクリメントされます。

 Button(
            onClick = { middleCount++ }, // ここでインクリメント
            modifier = Modifier.padding(bottom = 16.dp)
        ) {
            Text("Update Middle")
        }

ボタンを押すと状態が更新されるので、再コンポーズが起こります。このとき、どの層で再コンポーズが起こってもそれぞれの層でしか再コンポーズが起こりません。
つまりその他の層では再コンポーズが起きず、余計な処理が走りません。

では実際に確かめてみましょう。まずは起動すると以下の様なログが出力されます。

2024-11-10 10:28:10.041 14318-14318 Recompose  com.example.myapplication   D  RecomposeDemo composing
2024-11-10 10:28:10.058 14318-14318 Recompose  com.example.myapplication   D  TopLevel composing
2024-11-10 10:28:10.133 14318-14318 Recompose  com.example.myapplication   D  MiddleLevel composing
2024-11-10 10:28:10.135 14318-14318 Recompose  com.example.myapplication   D  BottomLevel composing

最初は全て順番に作成されるので上の階層からコンポーザブルが構築されます。 ではここで最下層のボタンを押下して再コンポーズを発生させてみます。ログは以下のようになりました。

2024-11-10 10:28:10.135 14318-14318 Recompose  com.example.myapplication   D  BottomLevel composing

中間層や最上層のコンポーザブルでは再コンポーズが起こっていません。これは(自分的には)直感的です。 次に最上層を再コンポーズしてみます。直感的には中間層と最下層も再コンポーズしそうですが…

2024-11-10 10:28:10.058 14318-14318 Recompose  com.example.myapplication   D  TopLevel composing

ログは最上位のものしか表示されません。つまり中間層と最下層では再コンポーズは行われずそのままの状態を維持しています。
中間層で同じことを行っても、最上位層や最下層に影響はありません。

これがJetpack Composeにおける再コンポーズの特徴です。

状態の管理

状態の変化はライフサイクルと関係があることが分かりました。
それを踏まえて状態の管理について見てみます。

内容は以下の公式サイトに沿っています。
状態と Jetpack Compose  |  Android Developers

まず、状態を管理する方法として一番基本的なものは「remember」と「mutableStateOf」を使う方法です。 先ほどの再コンポーズを確かめるコードに以下のような記述があったと思います。

var middleCount by remember { mutableStateOf(0) }

この方法で作成された変数は変更が監視され、変更があるとそのコンポーザブルの再コンポーズが起こります。
先ほどのコードでも、ボタンが押されることでカウンターが更新されて再コンポーズが起こっていました。

@Composable
private fun TopLevel(
    content: @Composable () -> Unit
) {
    Log.d("Recompose", "TopLevel composing")

    var topCount by remember { mutableIntStateOf(0) } // これが更新されると再コンポーズされる

    Column {
        Text(
            text = "Top Level (count: $topCount)",
            modifier = Modifier.padding(bottom = 8.dp)
        )

        Button(
            onClick = { topCount++ },
            modifier = Modifier.padding(bottom = 16.dp)
        ) {
            Text("Update Top")
        }

        content()
    }
}

ただし、この方法では状態が一時的にしか保存されません。例えば画面が回転したり、プロセスが終了すると状態は初期化されてしまいます。
これは設定変更によってアクティビティと呼ばれるものをシステムが破棄するから起こるようです。

詳しくは公式サイトで説明されています。
アクティビティのライフサイクル  |  Android Developers

この状態の破棄を防ぐには「rememberSaveable」を使用します。先ほどのコードの最上層を以下のように書き換えてみます。

@Composable
private fun TopLevel(
    content: @Composable () -> Unit
) {
    Log.d("Recompose", "TopLevel composing")

    var topCount by rememberSaveable { mutableIntStateOf(0) } // ここを書き換える

    Column {
        Text(
            text = "Top Level (count: $topCount)",
            modifier = Modifier.padding(bottom = 8.dp)
        )

        Button(
            onClick = { topCount++ },
            modifier = Modifier.padding(bottom = 16.dp)
        ) {
            Text("Update Top")
        }

        content()
    }
}

// 以下は同じ

まずは起動して、それぞれのボタンを5回ずつ押下してみます。

この状態で画面を横にします。すると…

最上層のカウントは保持されますが、それ以外の層のカウントは0に戻ってしまいました。
以上で状態が保持されることが確認できたかと思います。

ここまで見ると、rememberよりもrememberSaveableを使った方が良さそうですが、場合によって使い分ける必要があります。
例えば、rememberSaveableの方がメモリ消費量が比較的多くなるため、 乱用すると不必要にメモリを圧迫してパフォーマンスが落ちることがあります。

状態ホイスティング

状態ホイスティングとは、状態を上位のコンポーネントに持ち上げるデザインパターンです。
この方法を用いることで、より柔軟で保守性の高いコンポーネントを作成できます。

例えば以下のコードを見てください。

// ステートフルな実装(ホイスティング前)
@Composable
fun NameInput() {
    var name by remember { mutableStateOf("") }
    TextField(
        value = name,
        onValueChange = { name = it }
    )
}

これを状態ホイスティングを使用して書き直したものが以下になります。

// ステートレスな実装(ホイスティング後)
@Composable
fun NameInput(
    name: String,
    onNameChange: (String) -> Unit
) {
    TextField(
        value = name,
        onValueChange = onNameChange
    )
}

// 状態を管理する親コンポーネント
@Composable
fun NameScreen() {
    var name by remember { mutableStateOf("") }
    NameInput(
        name = name,
        onNameChange = { name = it }
    )
}

このように実装するとどのようなメリットがあるのでしょうか。

まず、再利用性が向上します。
先ほどのコードで言えば、NameInputは複数の入力フィールドに使い回すことができます。

@Composable
fun UserProfileForm() {
    var firstName by remember { mutableStateOf("") }
    var lastName by remember { mutableStateOf("") }
    var nickname by remember { mutableStateOf("") }
    
    Column(modifier = Modifier.padding(16.dp)) {
        NameInput(
            name = firstName,
            onNameChange = { firstName = it }
        )
        
        NameInput(
            name = lastName,
            onNameChange = { lastName = it }
        )
        
        NameInput(
            name = nickname,
            onNameChange = { nickname = it }
        )
        
        // 入力値を使った処理が可能
        Text("Full name: $firstName $lastName ($nickname)")
    }
}

バリデーションを追加することも容易です。

@Composable
fun ValidatedNameForm() {
    var name by remember { mutableStateOf("") }
    var isError by remember { mutableStateOf(false) }
    
    Column {
        NameInput(
            name = name,
            onNameChange = { 
                name = it
                isError = it.length < 3
            }
        )
        
        if (isError) {
            Text(
                text = "名前は3文字以上必要です",
                color = MaterialTheme.colorScheme.error,
                modifier = Modifier.padding(start = 16.dp)
            )
        }
    }
}

他のフィールドと連携することもできます。

@Composable
fun LinkedNameFields() {
    var firstName by remember { mutableStateOf("") }
    var lastName by remember { mutableStateOf("") }
    var fullName by remember { mutableStateOf("") }
    
    Column {
        NameInput(
            name = firstName,
            onNameChange = { 
                firstName = it
                fullName = "$it $lastName"
            }
        )
        
        NameInput(
            name = lastName,
            onNameChange = { 
                lastName = it
                fullName = "$firstName $it"
            }
        )
        
        // 読み取り専用の結合フィールド
        NameInput(
            name = fullName,
            onNameChange = { }  // 変更不可
        )
    }
}

これらは、NameInputが内部に状態を持たないステートレスな実装になっているからです。 他にもテストが容易になるといった利点もあります。

詳しくは公式サイトをご覧ください。
状態と Jetpack Compose  |  Android Developers

最後に

今回はJetpack Composeの基本について学びました。宣言型の記述はFlutterを少し触っていたので無理なく学習を進めることができました。 また、再コンポーズの仕組みは複雑ではありますが、パフォーマンスが考慮されていている良い仕組みだと感じました。 今回学んだ内容を活かして、次回はJetpack Composeを用いて実際にアプリケーションを構築してみようと思います。

競技かるたONLINEの裏話(その2)

こんにちは、Beta Computing株式会社の村松です。
前記事に引き続き「競技かるた ONLINE」の裏話をお話いたします。

開発秘話

漫画「ちはやふる」をきっかけに競技かるたに興味を持った私たちですが、競技かるたのルールは漫画を通して把握しているつもりでした。また、アプリ開発にあたり競技かるたのルールをインターネットで検索していました。
そして、自分たちなりに試行錯誤し、「競技かるた ONLINE」の原型となるアプリを開発しました。

その後、たまたま会社の近くにかるた教室があることを知ったので、開発したアプリを持ってお邪魔させていただきました。 皆様に感動してもらえるのではないかと期待して、アプリをお見せしましたが、以下のようなご指摘を受けました。

  • 札のフォントが公式札と違う
  • から札の扱い方が違う
  • 札押しの考え方が違う
  • 決まり字の考え方が違う
  • CPUの取るタイミングがおかしい
  • CPUの定位置がおかしい
  • CPUの送り札がおかしい
  • 暗記時間やから札が出た際の時間の使い方が気におかしい
  • 画面サイズ上25枚:25枚は並べられないのは仕方ないとして、友札を多くしてほしい

などなど

特にかるた教室に子どもたちの反応を見ると、ゲームアプリということで楽しそうに遊んではくれたものの、競技かるたという内容に対しては「ぜんぜんちがーう!」「えー!意味わからない!」という声が聞こえてきました。

更に、かるた教室の先生からは「あなた達は実際にかるたをやったことがないのではないか。まずは百首を覚えて、練習に参加し、大会に出なさい。」とごもっともなご意見を頂きました。 そこから、私達は1年以上かるた教室に通い、百首は当然暗記し、大会に出て少し勝ち進めるまでになりました。

漫画やインターネットで競技かるたのルールを理解していたつもりでしたが、実際に真剣にかるたに取り組んでみて、やっと当初の指摘の意味が分かってきました。

  • 札は大石天狗堂様の札でないと取りづらい
  • から札はルールとして必須、決まり字変化にも影響があり、暗記し直す時間にもなる
  • 取り判定の厳密なタイミング
  • CPUは決まり字が読まれてから取るべきだが、決まり字変化や残り枚数によってもスピードが変化するべき
  • 定位置や送り札にも戦略がある
  • アプリでは基本8枚:8枚の試合だが、決まり字変化を楽しむために、使われる札はランダムではなく、計算が必要

などなど

これらの経験や知識を反映させたものが、現在の「競技かるた ONLINE」でございます。 囲い手やフライング判定など、アプリ上ではどうしても再現させることができなかったルールもございますが、できる限り競技かるたのルールを再現するよう努力しております。

このように、インターネットでルールを理解したつもりで開発しましたが、実際のかるた競技者から見ると本質を理解できていないアプリだったということがわかりました。
全日本かるた協会様、各地域のかるた教室様、各大会で出会った選手様、大石天狗堂様、読手様等、皆様のご協力の上で開発することができたアプリです。改めてお礼申し上げます。本当にありがとうございます。

なお、このエピソードは「競技かるた ONLINE」だけではなく、ソフトウェア開発やスマートフォンアプリ開発全般に通ずるものがあると考えています。
以降弊社では、スマートフォンアプリを開発する際は、アプリを利用される業種の勉強や、現場の調査を十分に行い、設計・開発するよう心がけています。どのアプリも関係者の皆様のお力を借りながら、開発しております。

開発費

前記事でもお話しましたが、弊社のメイン事業はソフトウェア開発の受託業務でございます。 受託業務の合間に自社製品開発として「競技かるた ONLINE」の開発を進めておりました。

「競技かるた ONLINE」は開発着手から初回リリースまで約1年半かかっております。 その間にかるた教室に通ったり、受託業務の合間にアプリを何度も修正したりということもございますが、単純な期間だけでいうと約1年半になります。

「競技かるた ONLINE」の初回リリースまでにかかった費用は主に以下の通りです。

  • 人件費
  • 読手様の音声収録費
  • イラストデータの素材費
  • BGM・SEデータの素材費
  • サーバ費
  • 調査や関係者とお会いするための旅費交通費
  • 広告宣伝費

などなど

本来であれば多額の費用がかかりますが、各種補助金(小規模事業者持続化補助金、新製品開発補助金)を利用することで、約300万円の実費負担で開発することができました。
その他、サービス継続のために運営費としてサーバ費や保守対応費用がかかっております。

「競技かるた ONLINE」は会社の広告宣伝として、無料でかつ広告表示もできる限り少なくリリースしております。 ぜひとも弊社スマートフォンアプリ開発のBeta Computing株式会社を認識いただけると大変嬉しく思います。

終わりに

「競技かるた ONLINE」の開発や運営の裏話を中心にお話しましたが、私達も競技かるたを習って大会に出場することで、より一層競技かるたの奥深さと楽しさを知ることができました。同時に調査を進めることで、競技かるた界が抱える課題なども多く知ることができました。
弊社では「ちはやふる基金」「全日本かるた協会」「各大会への協賛」「地元かるた教室への貢献」等、少額ではありますが支援しております。 「競技かるた ONLINE」も競技かるた界の発展に少しでも役立つことを願って、開発と保守を続けてまいります。

もし弊社にご協力できそうなことがあればお気軽にお問い合わせください。 どうぞよろしくお願いいたします。

競技かるた ONLINEについてのお詫びと裏話(その1)

こんにちは、Beta Computing株式会社の村松です。 今日は「競技かるた ONLINE」についてお詫びと裏話をお話いたします。

Android版の「競技かるた ONLINE」がインストールできない件について

まず、現在(2024年6月7日時点) Android版「競技かるた ONLINE」がGoogle Play ストアからインストールできない状況となっております。大変申し訳ございません。 本件、ユーザー様からのお問い合わせも多数頂いており、期待してくれている方も多くいる中で、ご迷惑おかけしている状況です。

こちら、原因は判明しておりまして、現在対応中でございます。 2024年9月頃にアップデート完了する予定ですので、もうしばらくお待ちいただけますようお願いいたします。 同時に、機能追加や読手音声の追加も予定しておりますので、ご期待ください。

なお、「競技かるた ONLINE」の更新状況については、公式X(旧Twitter)の方でもお知らせしてまいります。 よろしければフォローいただければと思います。 https://x.com/karutaapp

Beta Computing株式会社について

本記事をご覧の方の中には、弊社についてご存じない方も多いと思いますので、まずは会社紹介をさせていただきます。

弊社は2015年に創業しました石川県の津幡町(金沢市の隣)にあるスマートフォンアプリ開発に特化したソフトウェア開発会社です。 「競技かるた ONLINE」や「かるた読唱 〜百人一首読み上げ〜」を自社製品としてリリースしておりますが、かるた専門の会社ではなく、ゲーム専門の会社でもございません。
(かるたもゲームも大好きです。今後もかるたアプリやゲームアプリも開発してまいります。)

弊社のメイン事業は、ソフトウェア開発の受託業務でございます。 もし興味を持っていただけましたら、弊社HPの会社概要開発実績をご覧いただければと思います。

「競技かるた ONLINE」の開発経緯について

「競技かるた ONLINE」は2019年4月25日にリリースしました。

karuta.betacomputing.co.jp

「競技かるた ONLINE」を開発したきっかけ

「競技かるた ONLINE」を開発したきっかけは大きく分けて2つございます。

1.Beta Computingの自社製品がなかった
弊社のメイン事業はソフトウェア開発の受託業務ですが、創業当時は営業活動に苦戦しておりました。

というのも、創業当時は会社としての開発実績がなかったために、お客様に弊社のアプリのイメージや弊社の技術力を伝えることが難しいという課題がございました。

また、ソフトウェア業界には「秘密保持契約」というものがございまして、弊社で開発したアプリでもお客様企業の名前で世の中にリリースされることが多く、弊社の名前は外に出ない上、弊社の開発実績として掲載・紹介できないものがいくつも存在します。
(現在は弊社HPに「開発実績」のページがございますが、それぞれお客様の許可を頂けたものを掲載させていただいております。お客様には感謝申し上げます。)

そこで、自社製品を開発することで、営業時に弊社の技術力を伝えやすくしようと考えました。 悩んだ結果、オンライン対戦のゲームアプリであれば、技術的な難易度が高いため、営業にも役立つという結論になりました。 また、ゲームアプリであれば一般ユーザーに楽しんでもらえる上、広告塔として弊社の知名度向上にも繋がるのではないかと考えました。

2.漫画「ちはやふる」がおもしろく、影響を受けた
ゲームアプリを開発しようと考えましたが、具体的にどのようなアプリにするかは決めておりませんでした。

ちょうどその頃、弊社メンバーが「ちはやふる」にハマっていたこともあり、社内でかるた大会をしようとしました。しかし、実際に札や読み上げ音声を用意するのは面倒なので、簡単にアプリで遊べるものがないか調べたところ、競技かるたのルールに沿ったゲームアプリがないことに気が付きました。

そこで、競技かるたのアプリを開発しようということになりました。「ちはやふる」のおかげもあり、楽しんで開発に取り組むことができました。

企画時の余談

余談ですが、「競技かるた ONLINE」企画当初は、ゲーム性を持たせ、相手の札を並び替えてしまうような自分に有利なアイテムや、相手を1回休みにするような相手が不利になるアイテムなどを設ける案もありました。競技かるたのルールを忠実に再現すると、百人一首をすべて覚えている前提になるので、ターゲットとなるユーザー数が少なすぎると考えたからです。そのため百人一首を知らないユーザーでも遊べる仕組みにしようという案がありました。 しかし、競技かるたのルールに沿ったゲームアプリがないというきっかけから開発を決めたアプリだったので、実際に競技かるたをやっているユーザーや、これから競技かるたを本格的に始めるユーザーが楽しめるアプリを開発することにしました。

開発面でのメリット

また、競技かるたを題材にしたゲームアプリは開発面でも多くメリットがありました。その中でも3つほど紹介します。

1.必要なイラスト素材が少ない
ゲームアプリを開発するとなると、例えばアクションゲームやRPGゲームであれば、ステージの数だけステージ検討やイラストデータの素材、キャラクターの素材などが必要になり、莫大な費用がかかってしまいます。開発当時、弊社は3人体制の小さな会社だったので、開発費と時間を極力抑える必要がありました。 かるたであれば、最低限100枚の札と畳の画像データ、読手音声のデータさえあれば開発できます。

2.札を飛ばす物理演算
競技かるたでは、漫画や映画で札を弾き飛ばしている様子が描かれますが、札押しというルールがあります。ただ札をタッチするだけではなく、ルールに沿って札を弾き飛ばしたいと考えました。また、ユーザーにとっても札を弾き飛ばせることが楽しいと考えたからです。 この札押しを再現するためには、物理演算という札の動きを計算して表現する必要があります。札押しの再現が技術力のアピールに繋がると考え開発しました。

その他にも、和の雰囲気を伝える見た目、説明書がなくても使いやすいUI/UXなどにこだわって開発しました。特に伝統的な日本語の文字遊びを意識しており、テキスト表示はメニュー画面を含め、縦書きになるようこだわっています。実は文字の縦書きをスマートフォンアプリ上で綺麗に見せることは大変難しく、他のアプリではあまり見られない高度な技術力アピールになっています。

3.リアルタイムオンライン対戦
スマートフォンアプリでは通信処理が絡むことが多いです。技術力宣伝のためにもネットワーク周りの処理を実装したかったのですが、かるたゲームであればリアルタイムにオンライン通信する必要があります。 リアルタイムオンライン対戦を実現することで、業務アプリの通信処理でも開発できる技術力があるというアピールに繋がります。

よくある質問

こちらも余談ですが、オンライン対戦についてはユーザー様からのお問い合わせも多いので、よくあるご質問2点にお答えします。

よくある質問1:オンライン対戦で自分の方が早く取ったにも関わらず、相手の取りになった
判定の取得秒数につきまして、決まり字が読まれてから取るまでの時間を端末内で0.001秒単位で計測し、何秒で取ったかを解析しております。オンライン対戦では各自計測した秒数を相手と突き合わせて、早い方の取りになります。ですので、取りの判定に誤りはございません。 しかし、自分もしくは相手の端末スペックが低い場合やネットワーク環境が悪い場合は、試合中に表示される取りのエフェクトが遅れて表示されることがございます。 特にネットワーク環境が悪い場合は、取ったことを対戦相手に伝えるまでの通信に時間がかかってしまうのでアプリ側ではどうしても改善できません。 そのため、エフェクトは遅れて表示されるが、実際は相手がもっと早く取っていたという状況が発生し、自分の方が早く取ったにも関わらず、相手の取りになったという見え方になってしまいます。 恐れ入りますが、取りの判定結果が正しいもので、エフェクトは参考程度に見えるものとご了承いただきますようお願いいたします。

よくある質問2:札押しをしたのに取り判定にならない
札の払いに関しましては、競技かるたの公式ルールに従いまして、札が競技線の外に完全に出た瞬間に取りの判定になります。札の一部でも競技線の内側に残っていると取り判定にはなりません。 ですので、札が競技線の外に完全出る前に、対戦相手に札直で触れられると相手の取りになります。

どちらもコンマ何秒の世界になりますので、技術的に実現が難しく、表現も難しいため、分かりづらい部分もあるかと思いますが、ご了承いただけると幸いです。

開発秘話 や 開発費と運営費 について

記事が長くなりましたので、次の記事に分けて書きたいと思います。

続きます。

大学や高専で会社紹介をさせていただきました

こんにちは、Beta Computing株式会社の村松です。

ブログの更新頻度が減っておりました。 閲覧してくれている方がいると信じて、今後更新回数を増やしていこうと思います。 よろしくお願いいたします。

講義活動について

2017年頃から教育機関での講義活動を依頼される機会が増え、大学、専門学校、高校で講師として登壇させていただくことが度々ございました。 ありがたいことに、以降毎年講師を務める機会をいただいております。

今年も、金沢大学、金沢工業大学、石川工業高等専門学校で講義を行いましたので、その内容をご紹介いたします。

金沢大学大学院「イノベーション方法論」

金沢大学大学院の「イノベーション方法論」では、私の起業の経緯と自己分析の重要性についてお話ししました。 対面授業でございましたが、講義室に入り切らない人数だったため同時にオンラインでの配信も行う形式でした。 真剣にお話を聞いていただき、鋭い質問も多く頂きました。

金沢大学融合学域スマート創成科学類「アントレプレナー演習Ⅰ」

金沢大学融合学域スマート創成科学類の「アントレプレナー演習Ⅰ」では、ISAと金沢大学の連携活動の一環として、弊社の紹介をさせていただきました。 スマート創成科学類は、文系理系の概念に囚われず、仮想と現実の融合を実装し,イノベーションの創成をリードする人材の養成を目的としております。 学生の皆様にはぜひ、弊社はもちろんのこと、石川県内の企業に興味を持っていただければと思います。 時間いっぱいまで多くの質問を頂き、講義後も先生方や学生の皆さまとお話する機会をいただきました。

金沢工業大学情報フロンティア学部メディア情報学科「進路セミナーⅠ」

金沢工業大学情報フロンティア学部メディア情報学科の「進路セミナーⅠ」では、地域での仕事や就職活動についてもお話ししました。 私も金沢工業大学出身ですので、金沢工業大学卒業から今に至るまでの話や、 地域で働くことの意味、就職活動において重要な要素である自己PRや志望動機の作成方法、面接の対策などについても説明しました。 レポート課題にて質問を多く頂きましたので、フィードバックをお送りしました。

石川工業高等専門学校電子情報工学科「実験3」

石川工業高等専門学校電子情報工学科の「実験3」では、お時間を頂いて弊社の紹介を行いました。 石川高専卒業生の弊社社員からも、卒業生としてアドバイスのメッセージを伝えました。

また、逆に高専生の皆様の活動をご紹介いただきました。興味深い活動ばかりで技術の高さと行動力に大変感銘を受けました。 授業後の放課後も、研究室の学生の皆様とお話する場を設けていただき、交流を深めることができました。

講義の振り返り

これらの講義を通じて出会った学生たちは、本当に素晴らしく、全員が非常に優秀でした。 私たちも負けていられないなと大きな刺激をもらいました。

また、学生の皆様に就職先の候補をお聞きしたところ、大手企業だけでなく、中小企業やベンチャー企業に興味がある学生も多くいることに驚きました。 自分で起業したいという学生もおり、将来について真剣に考えている様子が印象に残っています。

今回は講師という立場でお話させてもらいましたが、いつか皆様とビジネスパートナーとしてお話できれば幸いです。 ぜひBeta Computingという会社があることを覚えておいてもらえると嬉しいです。

採用活動のご案内

弊社では現在、採用活動を積極的に行っており、才能ある若い世代を迎え入れたいと考えています。 もし、弊社に興味を持っていただける方がいらっしゃいましたら、ぜひご応募いただきたいと思います。

弊社は、社員一人ひとりが持つスキルと個性を最大限に活かし、共に成長できる環境を提供しています。 特に、新しい技術やイノベーションに興味を持ち、自らの手で未来を切り拓いていきたいという意欲を持つ方を歓迎しています。

採用情報はこちら

また、弊社ではインターンシップも募集しております。 インターンシップは、学生たちが実際の業務を体験し、自分のキャリアを具体的に考える貴重な機会です。 実務経験を積むことで、より深い理解とスキルの向上が期待できます。 インターンシップに参加することで、学生たちは企業の文化や働き方を肌で感じ、自分の適性や興味を確認することができます。 弊社のインターンシップに興味がある方は、ぜひお問い合わせください。

インターンシップ情報はこちら

最後に

講義活動を通じて、多くの優秀な学生たちと出会えたことは私たちにとっても得るものが多くあります。 皆様の熱意には感銘を受けています。これからも教育機関との連携を深めていければと考えております。 引き続き、教育機関との共同研究やプロジェクトにも積極的に参加してまいりますので、お力添えのほどお願いいたします。

これからも皆様のご支援とご協力を賜りますよう、お願い申し上げます。どうぞよろしくお願いいたします。