Open14

【Android】「Compose を用いた Android アプリ開発の基礎」学習メモ

だーら(Flamers / Memotia)だーら(Flamers / Memotia)

ライフサイクルについて

  • "--- コメント ---"は、このメモにまとめる段階で追加したもの(Logcatでは出力していない)
--- 縦画面で起動する ---
onCreate Called
onStart Called
onResume Called
--- 横画面にする(変数に保存していたデータは破棄される) ---
onPause Called
onStop Called
onDestroy Called
onCreate Called
onStart Called
onResume Called
--- 再び縦画面に戻す(変数に保存していたデータは破棄される) ---
onPause Called
onStop Called
onDestroy Called
onCreate Called
onStart Called
onResume Called
--- ホームに戻る(アプリは終了させない) ---
onPause Called
onStop Called
--- 再び開く(初回起動ではない)(変数に保存していたデータが引き継がれている) ---
onRestart Called
onStart Called
onResume Called
--- アプリプロセスを終了する ---
onPause Called
onStop Called
---------------------------- PROCESS ENDED (27840) for package com.example.dessertclicker ----------------------------

アクティビティに関する重要な解説

  • ホームボタンを押した際のアクティビティについて解説

onPause() が呼び出されると、アプリのフォーカスが失われます。onStop() の後、アプリは画面に表示されなくなります。アクティビティは停止されていますが、Activity オブジェクトはメモリ内(バックグラウンド)に残っています。Android OS がアクティビティを破棄したわけではありません。ユーザーがアプリに戻ってくる可能性があるため、Android はアクティビティ リソースを保持しています。

  • フォアグラウンドに戻った時

アクティビティがフォアグラウンドに戻ったときに、onCreate() メソッドが再び呼び出されることはありません。アクティビティ オブジェクトは破棄されていないため再作成の必要はなく、onCreate() の代わりに onRestart() メソッドが呼び出されます。

onStartとonResumeの違い

  • onStart()...アプリが画面に表示されるようになる
  • onResume()...アプリがユーザーフォーカスを取得する

違いを顕在化させる

  • 共有ボタンを押して、オーバーレイでシステムのUIを表示させるとき。この間もアプリは操作できないが表示され続けている
onPause Called
  • 共有のUIを閉じてアプリに戻った時
onResume Called

注意

onPause() のみによる中断は通常、そのアクティビティに戻るか、別のアクティビティまたはアプリに移動するまでの短い時間しか持続しません。一般に、UI を継続的に更新して、アプリの残りの部分がフリーズしたように見えないようにする必要があります。

だーら(Flamers / Memotia)だーら(Flamers / Memotia)

状態の監視について

Composableの概念について

  • Compose...宣言的UIフレームワークの概念。「Composeシステム」のように読むと理解しやすい。
    • Compose appは、composable functionsを実行してデータをUIに変換する。
  • Composition...Compose(システム)が、composablesを実行したときに構築されるUIの記述のこと。
  • もしstateが変更したら
    • Compose(システム)が、影響を受けたcomposables functionsを再実行する。これによって、recompositionと呼ぶ更新されたUIが構築される。
    • Composeは、recompositionをスケジュールする。
  • Composeが、初回のcompositionでcomposablesを実行するとき、composablesをトラッキングする。
  • Composition(UIの記述)は、初回のcompositonによって生成され、recompositionによって更新される。
    • Compositionを変更する唯一の方法はrecompositionである。
  • State, MutableStateを使って、Composeに対してアプリの状態を観測させる。

再コンポーズさせる設定

  • remember関数を用いることで、インプットした際に、画面の表示が変更されるようにする。
    • なお、現時点では横向きにするなどしてActivityを終了するとインプットの値はリセットされてしまう
// 親のレイアウトから呼び出されるコード。値の入力を受け付けるインプットの実装部分
@Composable
fun EditNumberField(modifier: Modifier = Modifier) {
    Log.d("MainActivity", "EditNumberField")

    var amountInput by remember { mutableStateOf("") }
    TextField(
        value = amountInput,
        onValueChange = { amountInput = it },
        modifier = modifier
    )
}
  • 1文字記述するたびに、再コンポーズが実行されていることがログから分かる
UI画面

状態ホイスティング

  • コンポーズ可能な関数から状態を取り出して、自身より上位の関数に状態管理を引き渡すこと。以下2つのケースが該当する。
    • 状態を複数のコンポーズ可能な関数と共有する
    • 他で再利用可能なステートレスなコンポーザブルを作成する
  • ホイスティングを行うにあたり、元の関数に以下2つの新パラメータが追加される
    • 表示する現在の値(「自分で自分のことを管理しなくなったので、いつも自分がどういう値を表示すれば良いのか教えてほしい」という気持ち)
    • 変更されたときに実行するコールバックラムダ (T) -> Unit(「自分は変更を検知しても覚えてられないので、どこにどう情報を伝えればよいのか教えてほしい」という気持ち)
具体的なコード
@Composable
fun TipTimeLayout() {

    // 元々は自分の下位の関数(EditNumberField)が管理していた状態が自分の管轄になる。
    // そのため、他の関数(計算されたチップ料金の表示用関数)にも値を渡すことができる
    var amountInput by remember { mutableStateOf("") }
    val amount = amountInput.toDoubleOrNull() ?: 0.0
    val tip = calculateTip(amount)

    Column(
        ......
        EditNumberField(
            value = amountInput, ← 新たに渡すことになった
            onValueChange = { amountInput = it },, ← 新たに渡すことになった
            modifier = Modifier......
        )
        Text(
            text = stringResource(R.string.tip_amount, tip), ← ホイスティングしたことで他の関数にも共有出来る

// 親のレイアウトから呼び出されるコード。値の入力を受け付けるインプットの実装部分
@Composable
fun EditNumberField(
    value: String, ←新しく受け取ることになる
    onValueChange: (String) -> Unit, ← 新しく受け取ることになる
    modifier: Modifier = Modifier
) {
    // 元々自分が管理していたamountInput等は自分の管理下から外れる

    TextField(
        value = value, ← 渡された値を下に流す
        onValueChange = onValueChange, ← 渡された変更に対する指示(ラムダコールバック)を下に流す

構成が変更されても状態を維持し続けるためには

  • アプリを縦画面から横画面にするなどをすると、「構成の変更」が行われる。
  • 構成の変更でも値が失われないようにするためには、rememberSaveableを用いる(rememberを置き換える)
  • remember...再コンポーズの際に値を保存する
  • rememberSavable...再コンポーズと構成の変更時に値を保存する
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

アーキテクチャ(ViewModelの利用)

概念図

  • UI要素...画面を作るコンポーネント。Jetpack Composeを使用して記述。
  • 状態ホルダー...データの保持、UIへの公開、アプリロジックの処理を行う。ViewModelが例。
    • ViewModelでは、アクティビティが破棄されて再作成されても破棄されないようになっている。(構成変更時にViewModelオブジェクトを自動で保持する)
  • UI State(UI状態)...状態ホルダー(ViewModel)によって保持される。dataクラスで作る模様。
    • UI Stateは不変で作る(フィールドをvalで定義する)。これにより、複数のソースが同時にアプリの状態を変更しないことを保証できる。

実装例

  • 「英単語がScramble(文字並べ替え)されたものをユーザーが当てるゲーム。10問中の正解数をScoreとする」を例として説明

UI Stateクラス

  • 不変なdataクラスとして作成。viewモデルで一時的に保持するようなものよりも長いサイクルで持つデータのイメージ
GameUiState.kt
data class GameUiState(
    val currentScrambledWord: String = "",
    val score: Int = 0,
)

ViewModelクラス

  • StateFlow は、現在の状態や新しい状態更新の情報を出力するデータ保持用の監視可能な Flow です。その value プロパティは、現在の状態値を反映します。状態を更新してこの Flow に送信するには、MutableStateFlow クラスの value プロパティに新しい値を割り当てます。

GameViewModel.kt
class GameViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(GameUiState())
    // バッキングプロパティとして、オブジェクト自体ではなくゲッターから返す。
    val uiState: StateFlow<GameUiState> = _uiState.asStateFlow() // asStateFlow()によって読み取り専用になる

    // UI Stateのデータクラスには入らないが、ViewModelで管理したいState。
    // リアルタイムでユーザーがInputに入力した値を保持する。
    // UIの表示に更新や、ゲームロジック(正解判定)に利用する。
    var userGuess by mutableStateOf("")
        private set

    init {
       resetGame()
    }

    // ViewModelで管理しているStateを、UI側から更新する指示を出すためのメソッド。
    // UI側から直接値を書き換えずに、ViewModelクラスに変更させる(なのでusewGuessはprivate set)
    fun updateUserGuess(userInput: String) {
        userGuess = userInput
    }

    // publicとして公開し、UI側から呼ばれる。ゲームロジックメソッドもViewModelに書かれる。
    fun resetGame() {
       _uiState.value = GameUiState(currentScrambledWord = 新しいワードを決めるprivateメソッド())
    }

    // UIからsubmitボタンを押した際に呼ばれる。
    // _uiState.updateで、copyから新オブジェクトを作っている点に注目
    fun checkUserGuess() {
        if (userGuessが正解だったら) {
            _uiState.update { currentState -> currentState.copy(
                currentScrambledWord = 新しいワードを決めるprivateメソッド(),
                score = currentState.score.plus(正解ごとの加算量))
            }
            updateUserGuess("") // userGuessを空にする(次の問題に進むので)
        } else {
        }
    }

UI Elementの実装

  • MainActivityは今回はとても簡素で、GameScreen(コンポーズ可能な関数)を呼ぶだけ
MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
                ......
                ) {
                    GameScreen()
                }
} // MainActivity
  • UI Elementのコンポーズ可能な関数たち
GameScreen.kt
@Composable
// 引数としてGameViewModelを定義
// GameScreen()のみがステートフルな関数
fun GameScreen(
    gameViewModel: GameViewModel = viewModel()
) {
    // ViewModelのインスタンスから、collectAsState()を利用してuiStateにアクセスする
    val gameUiState by gameViewModel.uiState.collectAsState()

    Column(
    ) {
        GameLayout(
            currentScrambledWord = gameUiState.currentScrambledWord,
            userGuess = gameViewModel.userGuess,
            onUserGuessChanged = { gameViewModel.updateUserGuess(it) },
            onKeyboardDone = { gameViewModel.checkUserGuess() },
} // GameScreen()

// ステートレスな関数
@Composable
fun GameLayout(
    currentScrambledWord: String,
    onUserGuessChanged: (String) -> Unit,
    userGuess: String,
    onKeyboardDone: () -> Unit,
    ......
            Text( // 問題の表示
                text = currentScrambledWord
            )
            OutlinedTextField( // ユーザーの入力受付
                value = userGuess,
                onValueChange = onUserGuessChanged,
                keyboardActions = KeyboardActions(
                    onDone = { onKeyboardDone() }
                )
            }
} // GameLayout()
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

Composeで画面間を移動する

https://developer.android.com/codelabs/basic-android-kotlin-compose-navigation?hl=ja&continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fandroid-basics-compose-unit-4-pathway-2%3Fhl%3Dja%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fbasic-android-kotlin-compose-navigation#0

  • 複数の画面にまたがるアプリを開発する。
  • Navigationコンポーネントを利用して画面間を移動する方法をまとめる

概念について

  • NavHost: 現在のデスティネーションを表示するコンテナとして機能するコンポーザブル。NavHostの中に複数の画面をcomposableとして、そのルートと合わせて用意しておく。NavControllerを持っている。
  • NavController: デスティネーション間の移動を担当する。「指定のページに移動する」「一つ前に戻る」などの移動命令を担当する。
  • ルート...URLのような感じで、一つのページごとに割り当てられている識別子。このチュートリアルではenumを定義していて、enumのname(String)がルートになっている。

実装

  • まずはルートを定義しておく
enum class CupcakeScreen() {
    Start,
    Flavor,
    Pickup,
    Summary
}
  • NavHost
@Composable
fun CupcakeApp( // このコンポーザブルがMainActivityのonCreate()の中で呼ばれている
    viewModel: OrderViewModel = viewModel(),
    navController: NavHostController = rememberNavController(),
    modifier: Modifier = Modifier
) {
    Scaffold(
        // topBarを省略
    ) { innerPadding ->
        val uiState by viewModel.uiState.collectAsState()
        // ↓ここからNavHost!
        NavHost(
            navController = navController, // 移動を担当するnavControllerを保持する
            startDestination = CupcakeScreen.Start.name, // 最初の画面
            modifier = modifier.padding(innerPadding)
        ) {
            // 以下のようにcomposableを並べる
            composable(route = CupcakeScreen.Start.name) { // composable() 関数は NavGraphBuilder の拡張関数
                StartOrderScreen(
                    // ...省略...(コンポーザブルが必要とする引数を渡す)
                    onNextButtonClicked = {
                        viewModel.setQuantity(it) // 次へボタンが押された際にviewModelを変更しつつ、
                        navController.navigate(CupcakeScreen.Flavor.name) // navControllerを介して画面遷移
                    }
                )
            }
            composable(route = CupcakeScreen.Flavor.name) {
                SelectOptionScreen(
                    onNextButtonClicked = { navController.navigate(CupcakeScreen.Pickup.name) },
                    onCancelButtonClicked = { cancelOrderAndNavigateToStart(viewModel, navController ) },
                    onSelectionChanged = { viewModel.setFlavor(it) } // ここではNextボタンが押されたタイミングではなく、ラジオボタンが変更されたタイミングでviewModelを操作している
                )
            }
...省略...
}

// 一番最初まで戻る関数。「キャンセルボタン」(戻るではなく)が呼ばれた際に発火。
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
    viewModel.resetOrder()
    navController.popBackStack(CupcakeScreen.Start.name, inclusive = false)
}

アプリ内の戻るボタンの実装

  • (「戻る」ではなく、「上へ」と表現する模様?)
  • もし戻る場所があったら(バックスタックが存在していたら)UIを表示する、などの制御も行う
@Composable
fun CupcakeApp( // このコンポーザブルがMainActivityのonCreate()の中で呼ばれている
    viewModel: OrderViewModel = viewModel(),
    navController: NavHostController = rememberNavController(),
    modifier: Modifier = Modifier
) {
    val backStackEntry by navController.currentBackStackEntryAsState()
    val currentScreen = CupcakeScreen.valueOf(
        backStackEntry?.destination?.route ?: CupcakeScreen.Start.name
    ) // 型はCupcakeScreen(自作したenum)
    Scaffold(
        topBar = {
            CupcakeAppBar(
                currentScreen = currentScreen, // バーのタイトル表示に利用
                canNavigateBack = navController.previousBackStackEntry != null, // 戻る場所(バックスタック)があるときのみ表示する
                navigateUp = { navController.navigateUp() } // これだけで前に戻れる
            )
        }
    )
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

コルチーンを含めた、ネットワークに接続しないアプリ

  • 非同期処理を含めてみる
  • キーワード: 構造化された同時実行
作っているアプリ
  • PlayerOneとPlayerTwoが同時にStartして競争するアプリ
  • Start/Stopが可能。Resetで0に戻る。
実装コード
  • RaceParticipantのスクリプト
// レースを走るプレイヤー(Participantを管理するクラス)
class RaceParticipant(
    val name: String,
    val maxProgress: Int = 100,
    val progressDelayMillis: Long = 500L,
    private val progressIncrement: Int = 1,
    private val initialProgress: Int = 0
) {

    // delayというsuspend関数を含むため、run関数自体もsuspendである必要がある。
    suspend fun run() {
        while (currentProgress < maxProgress) {
            delay(progressDelayMillis)
            currentProgress += progressIncrement;
        }
    }

    var currentProgress by mutableStateOf(initialProgress)
        private set

    fun reset() {
        currentProgress = 0
    }
}
  • view用スクリプト
    • コンポーザブルの中からsuspend関数を安全に呼び出すには、LaunchEffect()コンポーザブルを使用する必要がある。
      • LaunchedEffect() コンポーザブルはコンポジションに残っている限り、指定された suspend 関数を実行します。

      • 逆にif (false)で消えると、suspend関数の実行をcancelする模様。
      • LaunchedEffect() がコンポジションを出ると、コルーチンはキャンセルされます。アプリでユーザーが [Reset] ボタンまたは [Pause] ボタンをクリックすると、LaunchedEffect() がコンポジションから削除され、それが起動したコルーチンもキャンセルされます。

// MainActivityから直接呼ばれるコンポーザブル関数
@Composable
fun RaceTrackerApp() {
    val playerOne = remember {
        RaceParticipant(name = "Player 1", progressIncrement = 4)
    }
    val playerTwo = remember {
        RaceParticipant(name = "Player 2", progressIncrement = 5)
    }
    var raceInProgress by remember { mutableStateOf(false) }

    // raceInPrograssがtrueからfalseに変わったタイミングでcoroutineScope内のsuspend関数がcancelされる。
    if (raceInProgress) {
        // この書き方だと、playerOneのrunが完了してからplayerTwoのrunが開始される
//        LaunchedEffect(playerOne, playerTwo) {
//            playerOne.run()
//            playerTwo.run()
//            raceInProgress = false
//        }

        // この書き方だと、すぐにraceInProgressがfalseになる(runの完了を待たない)
//        LaunchedEffect(playerOne, playerTwo) {
//            launch { playerOne.run() }
//            launch { playerTwo.run() }
//            raceInProgress = false
//        }

        // playerOneとplayerTwoが引数として渡されている。これはkeyという名前である。
        // もしこれらの変数が別のインスタンスで置き換えられたときに、コルーチンをキャンセルして再起動するため。
        LaunchedEffect(playerOne, playerTwo) {
            // coroutineScopeを用いることで、構造化された同時実行を実現する。
            // 階層を作り、サブタスクを並列に並べること。
            coroutineScope {
                launch { playerOne.run() }
                launch { playerTwo.run() }
            }
            raceInProgress = false
        }
    }
    RaceTrackerScreen(
        // コンポーザブルなUI表示
    )
}
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

インターネットに接続するコルーチン

ViewModelScope

  • ViewModelごとに定義されるコルーチンスコープ。ViewModelがクリアされると自動的にキャンセルされる。アプリの構成の変更が発生しても、ViewModelは破棄されないので、リクエストは続行される。
ViewModelからのコルーチンコード
fun getMarsPhotos() {
    viewModelScope.launch {
        // try catchで囲ってエラーハンドリング
        marsUiState = try {
            val listResult = marsPhotosRepository.getMarsPhotos() // これがsuspend関数
            MarsUiState.Success(結果を格納)
        } catch (e: IOException) {
            MarsUiState.Error
        }
    }
}

シリアル化

「シリアル化」は、アプリによって使用されるデータを、ネットワーク経由で転送できる形式に変換するプロセスです。「シリアル化解除」は、「シリアル化」とは反対に、外部ソース(サーバーなど)からデータを読み取ってランタイム オブジェクトに変換するプロセスです。

  • Kotlinのオブジェクトと、Jsonを行き来させること。
  • kotlinx.serializationライブラリなどを利用する。依存関係の設定などはこちら
  • データの受け取り用クラスを作成
Jsonの形式と、受け取り用のクラスの対応
[
    {
        "id":"424906",
        "img_src":"http://mars.jpl.nasa.gov/msl-raw-images/msss/01000/mcam/1000ML0044631300305227E03_DXXX.jpg"
    },
    ....
]
  • kotlinのデータクラス
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class MarsPhoto(
    val id: String,

    @SerialName("img_src") // Jsonから、Kotlin用の命名規則に変換するため
    val imgSrc: String
)
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

アーキテクチャ(データレイヤに焦点をあてた)と手動依存性注入

  • データレイヤは1つ以上のリポジトリで構成される。リポジトリには0個以上のデータソースが含まれる。
Repositoryの実装コード

通常版

data/MarsPhotosRepository.kt
// インターフェースを定義。この実装を付け替えていく。
interface MarsPhotosRepository {
    suspend fun getMarsPhotos(): List<MarsPhoto>
}

// ネットワークから取得するタイプのリポジトリ。MarsPhotosRepositoryインターフェースを実装している。
class NetworkMarsPhotosRepository(
    private val marsApiService: MarsApiService
) : MarsPhotosRepository {
    override suspend fun getMarsPhotos(): List<MarsPhoto> {
        return marsApiService.getPhotos()
    }
}

Fake版

  • testディレクトリ以下にあるので、通常のアプリケーションには関係ない
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository {
    override suspend fun getMarsPhotos(): List<MarsPhoto> {
        // 通常版はこの部分でAPIリクエストする。Fake版ではただFakeのデータを直接渡すだけ。
        return FakeDataSource.photoList // FakeDataSource.photoListではただのlistが返るだけ。
    }
}
APIに直接アクセスするクラスのインターフェースと実装

通常版

network/MarsApiService.kt
interface MarsApiService {
    @GET("photos")
    suspend fun getPhotos(): List<MarsPhoto>
}
  • 通常版では、MarsApiServiceインターフェースの実装は、DIコンテナ内で生成されている
data/AppContainer.kt
// このインターフェースは後ほど出てくる、依存性注入の際に利用
interface AppContainer {
    val marsPhotosRepository: MarsPhotosRepository
}

// 通常版の依存されるオブジェクトを生成しておくクラス。
class DefaultAppContainer : AppContainer {
    private val baseUrl = "https://android-kotlin-fun-mars-server.appspot.com"
    private val retrofit = Retrofit.Builder()
        .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
        .baseUrl(baseUrl)
        .build()

    private val retrofitService: MarsApiService by lazy {
        // ライブラリの実装なのでわかりにくいが、以下の行でMarsApiServiceの通常版(ネットワークにアクセスする版)をインスタンス化している。
        retrofit.create(MarsApiService::class.java)
    }

    override val marsPhotosRepository: MarsPhotosRepository by lazy {
        NetworkMarsPhotosRepository(retrofitService)
    }
}
Applicationコンテナから、viewModelへの依存性注入
  • DefaultAppContainerを作成
class MarsPhotosApplication : Application() {
    lateinit var container: AppContainer

    override fun onCreate() {
        super.onCreate()
        container = DefaultAppContainer()
    }
}
  • AndroidManifestに追加

クラスはアプリのオブジェクトから継承されるため、クラス宣言に追加する必要があります。

まだあんまり良く分かってない

<application
   android:name=".MarsPhotosApplication"
   android:allowBackup="true"
...
</application>

viewModelへのインジェクション

ViewModel を作成する際にコンストラクタで値を渡すことができません。この制限を回避するために ViewModelProvider.Factory オブジェクトを実装します。

  • Factoryパターンを用いるため、viewModel側に以下のように記述
    ``kt
    companion object {
    val Factory: ViewModelProvider.Factory = viewModelFactory {
    initializer {
    val application = (this[APPLICATION_KEY] as MarsPhotosApplication)
    val marsPhotosRepository = application.container.marsPhotosRepository
    MarsViewModel(marsPhotosRepository = marsPhotosRepository)
    }
    }
    }
- その後、viewModelをインスタンス化している部分でFactoryを使用するよう変更する
```kt
val marsViewModel: MarsViewModel = viewModel(factory = MarsViewModel.Factory)
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

Applicationクラスとは

Application クラス、または Application クラスのサブクラスは、アプリケーション/パッケージのプロセスが作成されるときに、他のクラスよりも前にインスタンス化されます。このクラスは主に、最初の状態Activityが表示される前にグローバル状態を初期化するために使用されます。カスタムApplicationオブジェクトは慎重に使用する必要があり、まったく必要ない場合も多いことに注意してください。

https://developer.android.com/reference/android/app/Application#public-constructors_1

https://guides.codepath.com/android/Understanding-the-Android-Application-Class#custom-application-classes