その他の考慮事項

VIew から Compose への移行は純粋に UI に関連しています。ただし、安全かつ段階的な移行を行うためにはさまざまな考慮事項があります。このページでは、View ベースのアプリを Compose に移行する際の考慮事項について説明します。

アプリのテーマを移行する

マテリアル デザインは、Android アプリのテーマ設定に推奨されるデザイン システムです。

View ベースのアプリの場合、マテリアルには次の 3 つのバージョンが用意されています。

  • AppCompat ライブラリを使用したマテリアル デザイン 1(Theme.AppCompat.*
  • MDC-Android ライブラリを使用したマテリアル デザイン 2(Theme.MaterialComponents.*
  • MDC-Android ライブラリを使用したマテリアル デザイン 3(Theme.Material3.*

Compose アプリの場合、マテリアルには次の 2 つのバージョンが用意されています。

  • Compose マテリアル ライブラリを使用したマテリアル デザイン 2(androidx.compose.material.MaterialTheme
  • Compose マテリアル 3 ライブラリを使用したマテリアル デザイン 3(androidx.compose.material3.MaterialTheme

アプリのデザイン システムで最新バージョン(マテリアル 3)を使用する必要がある場合は、そうすることをおすすめします。View と Compose の両方の移行ガイドが用意されています。

Compose で新しい画面を作成するときは、使用しているマテリアル デザインのバージョンに関係なく、Compose Material ライブラリから UI を出力するコンポーザブルの前に MaterialTheme を適用してください。マテリアル コンポーネント(ButtonText など)は MaterialTheme が設定されているかどうかに左右され、設定されていない場合の動作は未定義になります。

Jetpack Compose サンプルはすべて、MaterialTheme 上に構築されたカスタムの Compose テーマを使用しています。

詳細については、Compose でのデザイン システムXML テーマを Compose に移行するをご覧ください。

アプリで Navigation コンポーネントを使用している場合は、Compose を使用したナビゲーション - 相互運用性Jetpack Navigation を Navigation Compose に移行するをご覧ください。

Compose と View が混在する UI をテストする

アプリの一部を Compose に移行した後は、何も破損していないことをテストで必ず確認する必要があります。

アクティビティまたはフラグメントで Compose が使用される場合、ActivityScenarioRule を使用する代わりに createAndroidComposeRule を使用する必要があります。createAndroidComposeRuleActivityScenarioRuleComposeTestRule と統合します。これにより、Compose コードと View コードを同時にテストできます。

class MyActivityTest {
    @Rule
    @JvmField
    val composeTestRule = createAndroidComposeRule<MyActivity>()

    @Test
    fun testGreeting() {
        val greeting = InstrumentationRegistry.getInstrumentation()
            .targetContext.resources.getString(R.string.greeting)

        composeTestRule.onNodeWithText(greeting).assertIsDisplayed()
    }
}

テストの詳細については、Compose レイアウトのテストをご覧ください。UI テスト フレームワークとの相互運用については、Espresso との相互運用UiAutomator との相互運用をご覧ください。

Compose を既存のアプリ アーキテクチャと統合する

Unidirectional Data Flow(UDF)アーキテクチャ パターンは、Compose とシームレスに連携します。アプリで Model View Presenter(MVP)のような他のアーキテクチャ パターンを使用している場合は、Compose を導入する前、または導入中に UI の該当部分を UDF に移行することをおすすめします。

Compose で ViewModel を使用する

Architecture ComponentsViewModel ライブラリを使用している場合、Compose とその他のライブラリで説明されているように、viewModel() 関数を呼び出すことで、任意のコンポーザブルから ViewModel にアクセスできます。

Compose を導入する場合、ViewModel 要素は View のライフサイクル スコープに従うため、異なるコンポーザブルで同じ ViewModel タイプを使用する際は注意が必要です。スコープは、Navigation ライブラリが使用されている場合は、ホスト アクティビティ、フラグメント、ナビゲーション グラフのいずれかになります。

たとえば、コンポーザブルがアクティビティでホストされている場合、viewModel() は常に同じインスタンスを返しますが、このインスタンスはアクティビティ終了時にのみクリアされます。次の例では、同じ GreetingViewModel インスタンスがホスト アクティビティ下のすべてのコンポーザブルで再利用されるため、同じユーザー(「user1」)にメッセージが 2 回表示されます。最初に作成された ViewModel インスタンスは他のコンポーザブルで再利用されます。

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

        setContent {
            MaterialTheme {
                Column {
                    GreetingScreen("user1")
                    GreetingScreen("user2")
                }
            }
        }
    }
}

@Composable
fun GreetingScreen(
    userId: String,
    viewModel: GreetingViewModel = viewModel(  
        factory = GreetingViewModelFactory(userId)  
    )
) {
    val messageUser by viewModel.message.observeAsState("")
    Text(messageUser)
}

class GreetingViewModel(private val userId: String) : ViewModel() {
    private val _message = MutableLiveData("Hi $userId")
    val message: LiveData<String> = _message
}

class GreetingViewModelFactory(private val userId: String) : ViewModelProvider.Factory {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return GreetingViewModel(userId) as T
    }
}

また、ナビゲーション グラフは ViewModel 要素のスコープを設定するため、ナビゲーション グラフ内のデスティネーションであるコンポーザブルには ViewModel の異なるインスタンスがあります。この場合、ViewModel のスコープはデスティネーションのライフサイクルに設定され、デスティネーションがバックスタックから削除されるとクリアされます。次の例では、ユーザーがプロフィール画面に移動すると、GreetingViewModel の新しいインスタンスが作成されます。

@Composable
fun MyApp() {
    NavHost(rememberNavController(), startDestination = "profile/{userId}") {
        /* ... */
        composable("profile/{userId}") { backStackEntry ->
            GreetingScreen(backStackEntry.arguments?.getString("userId") ?: "")
        }
    }
}

状態の信頼できる情報源

UI の一部に Compose を導入する場合、Compose と View システムコードでデータを共有する必要が出てくる可能性があります。可能であれば、両方のプラットフォームで使用される UDF ベスト プラクティスに即した別のクラスに、共有状態をカプセル化することをおすすめします。たとえば、データ更新を出力するために共有データのストリームを公開する ViewModel にカプセル化します。

ただし、共有するデータが可変型であったり、UI 要素と密接に結びついていたりすると、そうはいかない場合もあります。その場合、一方のシステムが信頼できる情報源であり、もう一方のシステムとデータの更新を共有する必要があります。一般的な経験則として、信頼できる情報源は、UI 階層のルートに近い方の要素が所有する必要があります。

信頼できる情報源としての Compose

SideEffect コンポーザブルを使用して、Compose 以外のコードに Compose 状態をパブリッシュします。この場合、信頼できる情報源は、状態の更新を送信するコンポーザブルに保持されます。

たとえば、分析ライブラリを使用すると、カスタム メタデータ(この例では「ユーザー プロパティ」)を後続のすべての分析イベントにアタッチすることで、ユーザー全体をセグメント化できます。現在のユーザーのユーザータイプを分析ライブラリに伝えるには、SideEffect を使用してその値を更新します。

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

詳しくは、Compose における副作用をご覧ください。

信頼できる情報源としての View システム

View システムが状態を所有し、それを Compose と共有する場合は、状態を mutableStateOf オブジェクトでラップして、Compose がスレッドセーフになるようにすることをおすすめします。このアプローチを使用すると、信頼できる情報源がないため、コンポーズ可能な関数は簡略化されますが、View システムは、可変状態と、その状態を使用する View を更新する必要があります。

次の例では、CustomViewGroupTextViewComposeView(内部に TextField コンポーザブルがある)が含まれています。TextView は、ユーザーが TextField に入力した内容を表示する必要があります。

class CustomViewGroup @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : LinearLayout(context, attrs, defStyle) {

    // Source of truth in the View system as mutableStateOf
    // to make it thread-safe for Compose
    private var text by mutableStateOf("")

    private val textView: TextView

    init {
        orientation = VERTICAL

        textView = TextView(context)
        val composeView = ComposeView(context).apply {
            setContent {
                MaterialTheme {
                    TextField(value = text, onValueChange = { updateState(it) })
                }
            }
        }

        addView(textView)
        addView(composeView)
    }

    // Update both the source of truth and the TextView
    private fun updateState(newValue: String) {
        text = newValue
        textView.text = newValue
    }
}

共有 UI の移行

Compose に徐々に移行する場合は、Compose と View システムの両方で共有 UI 要素を使用しなければならないことがあります。たとえば、アプリにカスタム CallToActionButton コンポーネントがある場合、Compose と View ベースの両方の画面で使用する必要があるかもしれません。

Compose の場合、共有 UI 要素はコンポーザブルとなり、XML を使用してスタイル設定されている要素やカスタムビューの要素であるかに関係なく、アプリ全体で再利用できます。たとえば、カスタム外部リンクの Button コンポーネントに CallToActionButton コンポーザブルを作成するとします。

View ベースの画面でこのコンポーザブルを使用するには、AbstractComposeView から拡張するカスタム ビューラッパーを作成します。そのオーバーライドした Content コンポーザブルに、以下の例のように Compose テーマでラップして作成したコンポーザブルを配置します。

@Composable
fun CallToActionButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Button(
        colors = ButtonDefaults.buttonColors(
            containerColor = MaterialTheme.colorScheme.secondary
        ),
        onClick = onClick,
        modifier = modifier,
    ) {
        Text(text)
    }
}

class CallToActionViewButton @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : AbstractComposeView(context, attrs, defStyle) {

    var text by mutableStateOf("")
    var onClick by mutableStateOf({})

    @Composable
    override fun Content() {
        YourAppTheme {
            CallToActionButton(text, onClick)
        }
    }
}

コンポーザブル パラメータは、カスタムビュー内で可変変数になることに注意してください。これにより、カスタムの CallToActionViewButton ビューは、従来のビューのようにインフレータブルかつ使用可能になります。以下に、ビュー バインディングを使用した例を示します。

class ViewBindingActivity : ComponentActivity() {

    private lateinit var binding: ActivityExampleBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityExampleBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.callToAction.apply {
            text = getString(R.string.greeting)
            onClick = { /* Do something */ }
        }
    }
}

カスタム コンポーネントに可変状態が含まれている場合は、状態に関する信頼できる情報源をご覧ください。

プレゼンテーションからの状態の分離を優先する

伝統的に View はステートフルです。View は、表示する方法に加えて、表示する内容を記述するフィールドを管理します。View を Compose に変換する際は、状態ホイスティングで詳しく説明しているように、レンダリングされるデータを分離して単方向データフローを実現するよう努めてください。

たとえば、View には、「表示される」、「表示されない」、「消失した」のいずれかの状態を記述する visibility プロパティがあります。これは View の固有のプロパティです。View の可視性は他のコードによって変更されることがありますが、現在の可視性の状態を実際に認識するのは View 自身のみです。View が確実に表示されるようにするロジックはエラーが発生しやすく、多くの場合 View 自身に関連付けられます。

一方、Compose では、Kotlin の条件付きロジックを使用して、まったく異なるコンポーザブルを簡単に表示できます。

@Composable
fun MyComposable(showCautionIcon: Boolean) {
    if (showCautionIcon) {
        CautionIcon(/* ... */)
    }
}

設計上、CautionIcon は自身が表示されている理由を知る必要も考慮する必要もなく、visibility の概念はありません。それは Composition 内にあるかないかのいずれかです。

状態管理とプレゼンテーション ロジックを明確に分離することで、コンテンツを状態の UI への変換として表示する方法をより自由に変更できます。また、必要なときに状態をホイスティングできるので、コンポーザブルがより再利用しやすくなります。これは、状態の所有権の柔軟性がより高いためです。

カプセル化された再利用可能なコンポーネントを活用する

多くの場合、View 要素にはそれが存在する場所(Activity 内、Dialog 内、Fragment 内、または別の View 階層内のどこか)の情報が含まれています。それらはしばしば静的レイアウト ファイルからインフレートされるので、View の全体的な構造は非常に固定的なものになりがちです。その結果、結合が密になり、View の変更または再利用が困難になります。

たとえば、カスタム View は、特定の ID を持つ特定の型の子ビューを持っていると想定し、なんらかのアクションに応じてそのプロパティを直接変更します。これにより、それらの View 要素は互いに密結合されます。カスタム View は、子を見つけられない場合、クラッシュしたり破損したりすることがあります。また、子はカスタム View の親がないと再利用できないことがあります。

再利用可能なコンポーザブルを使用する Compose では、こうしたことはそれほど問題になりません。親は状態とコールバックを簡単に指定できるので、再利用可能なコンポーザブルは、それが使用される場所が正確にわからなくても記述できます。

@Composable
fun AScreen() {
    var isEnabled by rememberSaveable { mutableStateOf(false) }

    Column {
        ImageWithEnabledOverlay(isEnabled)
        ControlPanelWithToggle(
            isEnabled = isEnabled,
            onEnabledChanged = { isEnabled = it }
        )
    }
}

上記の例では、3 つの部分すべてがより高度にカプセル化され、結合が疎になっています。

  • ImageWithEnabledOverlay が知る必要があるのは、現在の isEnabled の状態だけです。ControlPanelWithToggle が存在するかどうか、またそれが制御可能かどうかを知る必要はありません。

  • ControlPanelWithToggleImageWithEnabledOverlay の存在を認識しません。isEnabled の表示方法は無指定にすることも、1 つまたは複数指定することもできます。ControlPanelWithToggle は変更の必要がありません。

  • 親にとって、ImageWithEnabledOverlay または ControlPanelWithToggle のネストの深さは問題になりません。このような子には、アニメーションの変更、コンテンツのスワップアウト、他の子へのコンテンツの引き渡しなどがあります。

このパターンは「制御の反転」と呼ばれます。詳しくは、CompositionLocal のドキュメントをご覧ください。

画面サイズの変更処理

レスポンシブな View レイアウトの主な作成方法の一つは、異なるウィンドウ サイズごとに異なるリソースを用意することです。従来のように、修飾されたリソースを使用して画面レベルのレイアウトを決定することもできますが、Compose では、通常の条件付きロジックを使用してもっと簡単にコード内でレイアウトを完全に変更できます。詳細については、ウィンドウ サイズクラスを使用するをご覧ください。

さらに、アダプティブ UI の作成用に Compose が提供する手法については、各種の画面サイズをサポートするをご覧ください。

View でのネストされたスクロール

(両方向でネストされている)スクロール可能な View 要素とスクロール可能なコンポーザブルの間でネストされたスクロールの相互運用を実現する方法について詳しくは、ネストされたスクロールの相互運用をご覧ください。

RecyclerView の Compose

RecyclerView バージョン 1.3.0-alpha02 以降、RecyclerView のコンポーザブルのパフォーマンスが向上しています。そのメリットを得るために、RecyclerView はバージョン 1.3.0-alpha02 以降を使用してください。

WindowInsets ビューとの相互運用

画面に同じ階層に View コードと Compose コードの両方がある場合は、デフォルトのインセットをオーバーライドする必要があります。この場合、どちらがインセットを使用するか、どちらが無視するかを明示的に指定する必要があります。

たとえば、最外側のレイアウトが Android View レイアウトの場合は、View システムでインセットを使用し、Compose では無視する必要があります。または、最外側のレイアウトがコンポーザブルの場合は、Compose でインセットを使用し、それに応じて AndroidView コンポーザブルをパディングする必要があります。

デフォルトでは、各 ComposeViewWindowInsetsCompat レベルの使用量ですべてのインセットを使用します。このデフォルトの動作を変更するには、ComposeView.consumeWindowInsetsfalse に設定します。

詳細については、Compose の WindowInsets のドキュメントをご覧ください。