Androidアプリ開発入門(お実装編)

2025/03/16に公開

はじめに

前回お勉強編をやりましたので、お実装編です。

https://zenn.dev/sc_tech/articles/82b817b98aba7e

Android Studioの使い方

Android Studioを使います。普段私はAI開発でVSCodeを使っており、あまりの操作の違いに困惑しております。
まずはAndroid Studioの使い方を...(インストールは終わっている前提です)

アプリ作成

  • Android Studioを起動してNew Projectをクリック

  • Empty Activityを選択(Jetpack Composeを使ったテンプレート)
    • ActivityはAndroidアプリの画面のことを指します

  • 設定をする
    • Nameは適当につける
    • Package nameはアプリのIDとして使われるのでユニークである必要がある(ドメインの逆順.アプリ名が一般的)
    • Save locationはそのままで良い(隠すために空欄にしてるだけです)
    • Minimum SDKで設定したVersion未満はインストールできなくなる

アプリ立ち上げ

エミュレーターを使って立ち上げます。

再生マークを押すとアプリが起動し、Hello Android!が出てくればOKです!

⚠️ 仮想デバイスが未作成の場合は作成します。(以下のCreate Virtual Deviceからできます)

操作の説明

便利なショートカットは...

  • Shiftを2回
    • ファイル名の検索ができます
    • VSCodeでいうところのcmd+Pです
  • cmd + Shift + F
    • ファイルの中身を検索できます
  • option + up
    • カーソルの選択範囲を段階的に大きくできます

最初に起動すると、実際のフォルダ構造になっていないので、これを変えてみます。
左上のAndroidProjectに変更します

デバック

Androidでは2種類ある

  1. 実行中のアプリにデバッカーをアタッチする
  2. アプリの起動と同時にデバッカーをアタッチする

デバッグ中画面詳細

適切なところでブレークポイントを設定しデバッグをすると、ブレークポイントで止まり、デバッグウィンドウが出てくる。

実際の値の確認や、計算も可能

簡素なTodoアプリ

機能

  • 投稿するとタスクが溜まる(データベース機能はなし)
  • 投稿した時の時刻も記録
  • 削除ボタンを押すと消える

アーキテクチャーの整理

完成イメージ

  • ToDoScreen
    • UI部分
    • TaskItem(1個のToDoを表すUI)を含む
  • ToDoUiState
    • UIの状態を表す
  • ToDoViewModel
    • UIから受け取った情報をもとにUIに状態を公開
  • ToDoRepository
    • ビジネスロジック部分
    • 今回だと時刻を生成する役割
  • Task
    • 1個のTaskのデータクラス

フォルダ構成

.
└── app/
    └── src/
        └── main/
            ├── java/
            │   └── {package_name}/
            │       ├── data/
            │       │   ├── repository/
            │       │   │   └── ToDoRepository.kt
            │       │   └── entity/
            │       │       └── Task.kt
            │       └── ui/
            │           ├── TaskItem.kt
            │           ├── ToDoScreen.kt
            │           ├── ToDoUiState.kt
            │           └── ToDoViewModel.kt
            └── res/
                └── ...

準備体操

アーキテクチャもフォルダ構成も決まったので、さぁやるぞ!となる前に、これを開発するのに使う知識を先に詰め込んでおきます。

TextFiledの作成

そのまま実装

一旦レイアウトとかは忘れてToDoScreen.ktにテキストボックスを作ろうとすると以下のようになります。(一応Previewもつけておきます)

@Composable
fun ToDoScreen(
    modifier: Modifier = Modifier
) {
    TextField(
        value = "",
        onValueChange = {},
        modifier = modifier
    )
}

@Preview
@Composable
fun ToDoScreenPreview() {
    ToDoScreen()
}

エミュレーターを起動して出てきたテキストボックスに値を入れると...入力できません!

これはテキストボックスに値を入れると、確かに値は更新されているのですが、UIが更新されているわけではない(Recomposeされていない)ことが原因です。
そこで、MutableStateを使ってTextFieldの中にある値をComposeに監視してもらい、変わったらRecomposeされるようにします。

先ほどとの変更点

  • textという変数を導入
    • 再代入できるvarを使いましょう
    • byはDelegated propetryといってMutableState型をStringやintとして扱えるようになります
    • rememberをつけると、Recomposeされても値がリセットされないようになるAPI
@Composable
fun ToDoScreen(
    modifier: Modifier = Modifier
) {
    var text: String by remember { mutableStateOf("") }

    TextField(
        value = text,
        onValueChange = {value -> text = value},
        modifier = modifier
    )
}

これで値が入れられるようになりました!

少しリファクタリング

状態を監視するものとUIは分けて実装します。
変更する場所は以下です。

  1. gradle/libs.version.tomlapp/build.gradle.ktsに追記してviewModelを取得できるようにする
  2. UIの状態を定義するToDoUiState.kttextを定義
  3. UIの状態を管理しUIに公開するためのToDoViewModel.ktTextFieldonValueChangeの中身を記載
  • 1について
    以下追記してsyncする。
[versions]
lifecycleViewModelCompose = "2.8.7"

[libraries]
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewModelCompose" }
dependencies {
    implementation(libs.androidx.lifecycle.viewmodel.compose) // ✅ TOML のエイリアスを正しく参照
}

  • 2について
    • データクラスとして定義
    • 画面が持っているデータはテキストボックスに入るテキストだけなので1個だけ定義
package com.example.todoapp2.ui

data class ToDoUiState(
    val text: String = ""
) {

}
  • 3について
    • UIが持つべき変数をuiStateとして、先に定義したToDoUiStateを入れる
    • 必要なメソッドは「テキストボックスに値が入ったら更新する」のみ
package com.example.todoapp2.ui

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel

class ToDoViewModel: ViewModel() {
    var uiState by mutableStateOf(ToDoUiState())
        private set

    fun inputText(text: String) {
        uiState = uiState.copy(text = text)
    }
}

そして、ToDoScreen.ktも変更します。

@Composable
fun ToDoScreen(
    modifier: Modifier = Modifier,
    viewModel: ToDoViewModel = viewModel(),
) {

    val uiState = viewModel.uiState
    TextField(
        value = uiState.text,
        onValueChange = viewModel::inputText,
        modifier = modifier
    )
}

TextFiledの強化

テキストボックスにボタンをつけて、ボタンを押した際にテキストボックスに入ってる文字がTextで表示されるようにしてみます。

変更箇所は以下になります。

  • ToDoScreen.kt
    • TextFieldにTrailingIconをつける(TrailingIconをつけるとテキストボックスの最後にアイコンをつけることができます)
    • この中にIconButtonを定義し、Clickされた時の処理、表示するアイコンなどの設定をしていく
    • またボタンが押された際に表示されるTextも用意しておく
    • そのまま書くと重なって見れないので、Columnで縦に並べる
@Composable
fun ToDoScreen(
    modifier: Modifier = Modifier,
    viewModel: ToDoViewModel = viewModel(),
) {

    Column (modifier = modifier) {
        val uiState = viewModel.uiState
        TextField(
            value = uiState.text,
            onValueChange = viewModel::inputText,
            modifier = modifier,
            trailingIcon = {
                IconButton(
                    onClick = { viewModel.addToDo(uiState.text) },
                    enabled = uiState.text.isNotBlank()
                ) {
                    Icon(
                        imageVector = Icons.Default.Add,
                        contentDescription = null
                    )
                }
            }
        )
        Text(text = uiState.displayText)
    }
}

Addボタン以外にも色々あります。こちらのページがまとまっています。
https://engawapg.net/jetpack-compose/1875/material-icon/

  • ToDoUiState.kt
    • UIの状態として表示するtextが増えたのでval displayText: String = ""を追加する
  • ToDoViewModel.kt
    • ボタンが押された時の処理を書く
class ToDoViewModel: ViewModel() {
    ...
    fun addToDo(text: String) {
        uiState = uiState.copy(displayText = text)
    }
}

できた!

実装

ファイル作成

最初に定義したフォルダ構成に合わせてファイルを作成していきたいと思います。
Task.ktToDoUiState.ktはデータクラスとして作成してください。
データクラスはデータの保持に適したクラスで、equals()copy()などが使えるようになります(後で出てきます)

このようになればOKです。

Screenの作成

  • ToDoScreen.kt
    • UIをTODO一覧が載る場所TODOを入力する場所の2つをColumnで分ける
    • TODO一覧はTaskListというComposable関数で定義
@Composable
fun ToDoScreen(
    modifier: Modifier = Modifier,
    viewModel: ToDoViewModel = viewModel(),
) {
    Column (modifier = modifier) {
        val uiState = viewModel.uiState
        val scrollState = rememberScrollState()
        TaskList(
            tasks = uiState.tasks,
            modifier = modifier
                .weight(1f)
                .verticalScroll(scrollState)
        )
        TextField(
            value = uiState.inputText,
            onValueChange = viewModel::inputText,
            modifier = modifier.fillMaxWidth(),
            trailingIcon = {
                IconButton(
                    onClick = viewModel::addToDo,
                    enabled = uiState.inputText.isNotBlank()
                ) {
                    Icon(
                        imageVector = Icons.Default.Add,
                        contentDescription = null
                    )
                }
            }
        )
    }
}

@Composable
private fun TaskList(
    tasks: List<Task>,
    modifier: Modifier = Modifier,
    viewModel: ToDoViewModel = viewModel(),
) {

    val scrollState = rememberScrollState()
    Column(modifier = modifier) {
        tasks.forEach { task ->
            TaskItem(
                task = task,
                onDeleteButtonClick = { viewModel.deleteToDo(task) }
            )
            Divider()
            }
    }
}
  • TaskItem.kt
    • TaskListの中の1個のToDoを定義する
@Composable
fun TaskItem(
    task: Task,
    onDeleteButtonClick: () -> Unit = {},
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier.padding(vertical = 8.dp),
    ) {
        Column (
            modifier = Modifier.weight(1f).padding(8.dp)
        ) {
            Text(text = task.title)
            Text(text = task.createAt, fontSize=10.sp)
        }
        IconButton(onClick = onDeleteButtonClick) {
            Icon(
                imageVector = Icons.Default.Delete,
                contentDescription = null
            )
        }
    }
}

UIが持つべき状態の定義

  • Task.kt
    • ToDo1個が持つべき状態
data class Task (
    val title: String = "",
    val createAt: String = ""
)
  • ToDoUiState.kt
    • UI全体が持つべき状態
    • 1個目はToDoリスト全て
      • つまりList型のTaskデータクラスの集合
    • 2個目はTextField
data class ToDoUiState(
    // 初期値としてtask1, 2024-11-10とtask2, 2024-11-12を定義
    val tasks: List<Task> = listOf(
        Task("task1", "2024-11-10"),
        Task("task2", "2024-11-12")
    ),
    val inputText: String = ""
)

UIの状態を管理するviewModel

  • ToDoViewModel.kt
    • UIの状態を更新したり制御するクラス
    • ex: ToDoを追加、削除など
class ToDoViewModel(
    private val repository: ToDoRepository = ToDoRepository()
): ViewModel() {
    var uiState by mutableStateOf(ToDoUiState())
        private set

    fun inputText(text: String) {
        uiState = uiState.copy(inputText = text)
    }

    fun addToDo() {
        val currentDateTime = repository.getDateTime()
        uiState = uiState.copy(
            tasks = uiState.tasks + Task(
                    title = uiState.inputText,
                    createAt = currentDateTime
            ),
            inputText = ""
        )
    }

    fun deleteToDo(task: Task) {
        uiState = uiState.copy(
            tasks = uiState.tasks - task
        )

    }
}

ビジネスロジック部分の定義

といっても今回はタスク投稿時間を裏で取得するだけなのですが...

  • ToDoRepository.kt
class ToDoRepository {
    fun getDateTime(): String {
        val currentDateTime = LocalDateTime.now()

        val year = currentDateTime.year
        val month = currentDateTime.monthValue
        val dayOfMonth = currentDateTime.dayOfMonth
        val hour = currentDateTime.hour
        val minute = currentDateTime.minute
        val second = currentDateTime.second

        return "$year-$month-$dayOfMonth $hour:$minute:$second"
    }
}

成果物

できた!

Discussion