Zenn
Open7

【SwiftUI / Jetpack Compose】ビューのライフサイクルについて調べる

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

親の状態に変化があったとき、親・子のビューはどう変化するか?

SwiftUI

  • 変化する状態自体を保持している親ビューは、再構築されない
  • 子ビューは、変化する状態を参照している・いないにかかわらず再構築される
コード
struct ContentView: View {
    @State private var count = 0

    init() {
        print("ContentView init \(count)")
    }

    var body: some View {
        VStack {
            Text("ContentView: \(count)")
            Button("increment") {
                count += 1
                print("=== ContentView Button Pressed ===")
            }
            Spacer().frame(height: 30)
            ChildCountView(count: count)
            Spacer().frame(height: 30)
            ChildNoCountView()
        }
    }
}

struct ChildCountView: View {
    let count: Int

    init(count: Int) {
        self.count = count
        print("ChildCountView init \(self.count)")
    }

    var body: some View {
        VStack {
            Text("ChildCountView \(count)")
        }
    }
}

struct ChildNoCountView: View {
    init() {
        print("ChildNoCountView init")
    }

    var body: some View {
        VStack {
            Text("ChildNoCountView")
        }
    }
}

Jetpack Compose

  • 状態を保持する親と、状態を参照する子のComposableが再コンポーズされる
  • 状態を参照していない子は再コンポーズされない
コード
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            LifecyclelearningTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    ContentView()
                }
            }
        }
    }
}

@Composable
fun ContentView() {
    var count by remember { mutableStateOf(0) }
    println("ContentView() called: $count")

    Column {
        Text(text = "ContentView $count")
        Button(onClick = {
            count++
            println("=== ContentView Button Pressed ===")
        }) {
            Text("increment")
        }
        Spacer(modifier = Modifier.height(30.dp))
        ChildCountView(count)
        Spacer(modifier = Modifier.height(30.dp))
        ChildNoCountView()
    }
}

@Composable
fun ChildCountView(
    count: Int
) {
    println("ChildCountView() called: $count")

    Column {
        Text(text = "ChildCountView $count")
    }
}

@Composable
fun ChildNoCountView() {
    println("ChildNoCountView() called")

    Column {
        Text(text = "ChildNoCountView")
    }
}

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

SwiftUIのViewのidentityについて

Explicit Identity

  • 明示的にViewにidentityを付与し、identityを変更するとViewが再構築される。
コード
struct ContentView: View {
    @State private var id = 1
    init() {
        print("ContentView init")
    }

    var body: some View {
        VStack {
            ChildView()
                .id(id)

            Button("increment id") {
                id += 1
                print("=== increment id ===")
            }
        }
    }
}

struct ChildView: View {
    init() {
        print("ChildView init")
    }

    var body: some View {
        Text("ChildView")
    }
}

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

AndroidのDisposableEffectの挙動について

  • 画面遷移の際に、前後の画面に設定されているDisposableEffect, onDisposeの発火順番について実験した
  • https://developer.android.com/develop/ui/compose/side-effects?hl=ja#disposableeffect
  • 実験
    • 画面A→画面B→画面Cと遷移する
    • それぞれの画面は独立しており、画面遷移すると元の画面のComposeは破棄される
  • 結論
    • 遷移前の画面のonDisposeは、遷移後の画面のDisposableEffect内に書かれた処理よりも後に行われる
    • ただし、三段階の推移をすると、最初の画面のonDisposeは、最後の画面のDisposableEffectよりも先に行われる
実験の詳細
  • 3画面を用意し、それぞれをnavigationによって遷移させる(3タブを用意する)
  • 各画面のComposable関数の中に、以下のDisposableEffectを用意する
    DisposableEffect(Unit) {
        println("[Debug] 画面AのDisposableEffectが発火")
        onDispose {
            println("[Debug] 画面AのonDisposeが発火")
        }
    }

画面A→画面Bと遷移


遷移前は、表示されたタイミングで発火した画面Aのログがある

遷移すると、先に画面Bが呼ばれ、その後で画面AのonDisposeが呼ばれる。この間0.5秒くらい

画面A→画面B→画面Cと遷移


遷移前

遷移後

  • かなり速い速度(画面AのonDisposeが呼ばれる前に画面Cを表示しようと試みる速度)で遷移させている
  • 画面CのDisposableEffectより、画面AのonDisposeが先に呼ばれる
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

Jetpack Composeの再コンポーズ

実験

まずは基本

  • Kotlinのバージョンは1.9.0

data class User(val name: String)

@Composable
fun RecomposeSkippingSample() {
    var count by remember { mutableIntStateOf(0) }
    var user by remember { mutableStateOf(User("Kota")) }

    Column {
        Button(onClick = {
            count++
        }) {
            Text("Increment count: $count")
        }
        CountView(count = count)
        UserView(user = user)
        JustTextView()
    }


    SideEffect {
        println("RecomposeSkippingSample composed")
    }
}

@Composable
private fun CountView(count: Int) {
    Text("Count: $count")

    SideEffect {
        println("CountView composed")
    }
}

@Composable
private fun UserView(user: User) {
    Text("User: ${user.name}")

    SideEffect {
        println("UserView composed")
    }
}

@Composable
private fun JustTextView() {
    Text("JustText")

    SideEffect {
        println("JustTextView composed")
    }
}

出力

CountView composed
UserView composed
JustTextView composed
RecomposeSkippingSample composed
CountView composed ←ボタンを押した直後
RecomposeSkippingSample composed ←ボタンを押した直後
  • Userの全ての公開プロパティが安定(不変なString)なので、Userクラスは安定 → 再コンポーズがスキップされる
  • 引数を持たないJustTextViewの再コンポーズもスキップされる

条件変更

  • Userクラスのプロパティを可変に変更する。data class User(var name: String)
  • ボタンを押した後のログが以下のように変化
CountView composed
UserView composed
RecomposeSkippingSample composed
  • また、var user by remember { mutableStateOf(User("Kota")) }val user = remember { User("Kota") }と変更しても結果は同じである。

条件変更

  • Kotlinのバージョンを2.0.21に変更する
  • val user = remember { User("Kota") }と記述した状態で、userのnameをbuttonを押した際に変更する
    • var user by remember { mutableStateOf(User("Kota")) }でもdata class User(var name: String)でも両方とも同じ結果
  • 結果、ボタンを押した後ログの出力は下記であり、UserViewが再コンポーズされていない。画面表示も変わっていない(nameがKotaからKota2に変わったのにそのまま)
CountView composed
RecomposeSkippingSample composed
  • これはStrong Skipping Modeにより、不安定なUserクラスは、===(参照同一性)で判断されるためである。プロパティを替えたとしても参照は同一なので同じものとみなされる。

Strong Skipping Modeでも変更されるようにするために

  • データクラスを不変に変更data class User(val name: String)
@Composable
fun RecomposeSkippingSample() {
    var count by remember { mutableIntStateOf(0) }
    var user by remember { mutableStateOf(User("Kota")) }

    Column {
        Button(onClick = {
            count++
            user = user.copy(name = "Kota2")
        }) {
            Text("Increment count: $count")
        }
  • なおこれではだめ
    • Stateを用いないとコンポーズが変更を検出できないからだと思われる
@Composable
fun RecomposeSkippingSample() {
    var count by remember { mutableIntStateOf(0) }
    var user = remember { User("Kota") }

    Column {
        Button(onClick = {
            count++
            user = user.copy(name = "Kota2")
        }) {
            Text("Increment count: $count")
        }
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

Jetpack Comoseのrememberについて

実験
@Composable
fun ContentView() {

    var count by remember {
        println("rememberのラムダ実行")
        mutableIntStateOf(0)
    }

    Column {
        Button(onClick = {
            count++
        }) {
            Text("increment")
        }
...
  • 上記のように記述した場合、rememberのラムダ実行がログに出力されるのは最初の1回だけで、それ以降countを変化させても出力されない
  • 画面回転などをしてActivityを再生成し、コンポジションを再生成すると再びrememberのラムダ実行が出力される
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

Jetpack Composeのコルーチンについて

LaunchedEffectでの呼び出し
@Composable
fun CoroutineSample() {
    var isWait by remember { mutableStateOf(true) }

    Column {
        Button(onClick = { isWait = !isWait }) {
            Text("Toggle WaitView")
        }
        if (isWait) {
            WaitView()
        } else {
            NotWaitView()
        }
    }
}

@Composable
private fun WaitView() {
    LaunchedEffect(key1 = Unit) {
        println("=== WaitView LaunchedEffect() called ===")
        waitFunc()
        println("=== WaitView LaunchedEffect() finished ===")
    }
    Text("WaitView")
}

@Composable
private fun NotWaitView() {
    Text("NotWaitView")
}

suspend fun waitFunc() {
    println("=== waitFunc() called ===")
    delay(3000)
    println("=== waitFunc() finished ===")
}
  • WaitViewが表示されて3秒以上待つと、=== waitFunc() finished === === WaitView LaunchedEffect() finished ===のログが出力される
  • WaitViewが表示されてすぐにボタンをタップし、WaitViewをコンポジションから削除すると、上記のログは表示されない
rememberCoroutineScopeでの呼び出し
@Composable
fun CoroutineSample() {
    val scope = rememberCoroutineScope()

    Column {
        Button(onClick = {
            scope.launch {
                println("=== Button Pressed ===")
                delay(3000)
                println("=== Button Pressed Finished ===")
            }
        }) {
            Text("StartCoroutine")
        }
        Button(onClick = {
            println("=== CancelCoroutine Button Pressed ===")
            scope.cancel()
        }) {
            Text("CancelCoroutine")
        }
    }
}
  • CancelCoroutineのボタンを押さなければ=== Button Pressed Finished ===のログが出力されるが、ボタンを押すと出力されない
=== Button Pressed ===
=== Button Pressed Finished ===
=== Button Pressed ===
=== CancelCoroutine Button Pressed ===
ログインするとコメントできます