其他考量

雖然從 View 遷移至 Compose 純粹與 UI 相關,但為了執行安全及漸進式的遷移作業,需要考量許多事項。本頁面包含將以 View 為基礎的應用程式遷移至 Compose 的部分注意事項。

遷移應用程式主題

Material Design 是為 Android 應用程式設定主題的推薦設計系統。

以 View 為基礎的應用程式有三種 Material 版本:

  • Material Design 1 使用 AppCompat 程式庫 (即 Theme.AppCompat.*)
  • Material Design 2 使用 MDC-Android 程式庫 (即 Theme.MaterialComponents.*)
  • Material Design 3 使用 MDC-Android 程式庫 (即 Theme.Material3.*)

Compose 應用程式有兩個可用的 Material 版本:

  • Material Design 2 使用 Compose Material 程式庫 (即 androidx.compose.material.MaterialTheme)
  • Material Design 3 使用 Compose Material 3 程式庫 (即 androidx.compose.material3.MaterialTheme)

如果能適用應用程式的設計系統,建議您使用最新版 Material 3。我們為 View 和 Compose 都提供了遷移指南:

無論使用的 Material Design 版本為何,在 Compose 中建立新畫面時,請務必先套用 MaterialTheme,再讓任何可組合項從 Compose Material 程式庫發出 UI。Material 元件 (ButtonText 等) 需要有 MaterialTheme,如果沒有,這些元件的行為將處於未定義狀態。

所有 Jetpack Compose 範例都會使用以 MaterialTheme 為基礎建構的自訂 Compose 主題。

詳情請參閱「Compose 中的設計系統」和「將 XML 主題遷移至 Compose」。

如果您在應用程式中使用導覽元件,請參閱「使用 Compose 進行導覽 - 互通性」和「將 Jetpack Navigation 遷移至 Navigation Compose」瞭解詳情。

測試混合的 Compose/View 使用者介面

將應用程式的部分內容遷移至 Compose 後,請務必進行測試,以確保沒有任何內容遭到毀損。

當活動或片段使用 Compose 時,您需要使用 createAndroidComposeRule,而不是 ActivityScenarioRulecreateAndroidComposeRule 可將 ActivityScenarioRuleComposeTestRule 整合,讓您同時測試 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 與現有的應用程式架構

單向資料流 (UDF) 架構模式可與 Compose 完美搭配運作。不過,如果應用程式改用 Model View Presenter (MVP) 等其他類型的架構模式,建議您在使用 Compose 之前或使用期間,將該部分的 UI 遷移至 UDF。

在 Compose 中使用 ViewModel

如果您使用架構元件 ViewModel 程式庫,可以呼叫 viewModel() 函式,從任何可組合項存取 ViewModel,如「Compose 和其他程式庫」一文中所述。

如果採用 Compose,在不同的可組合函式中使用相同的 ViewModel 類型時請務必謹慎,因為 ViewModel 元件會追蹤檢視畫面生命週期的範圍。如果使用導覽資料庫,範圍會限定為代管活動、片段或導覽圖。

舉例來說,如果可組合項在活動中代管,viewModel() 一律會傳回相同的例項,系統只會在活動完成後清除這個例項。在以下範例中,系統會歡迎同一位使用者 ("user1") 兩次,原因在於代管活動下的所有可組合項重複使用了相同的 GreetingViewModel 例項。第一個建立的 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。

在以下範例中,CustomViewGroup 包含 TextView,以及內有 TextField 可組合函式的 ComposeViewTextView 必須顯示 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 本身有關。

相較之下,在使用 Kotlin 中有條件的邏輯時,Compose 能輕鬆顯示完全不同的可組合元件:

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

在設計上,CautionIcon 沒有必要瞭解或在意其顯示的原因,也沒有 visibility 的概念:它如果不在 Composition 中,便是不在。

只要將狀態管理和呈現邏輯明確區隔,即可輕鬆將顯示內容方式變更為 UI 狀態的轉換項目。因為狀態擁有權更為靈活,在需要時能夠提升狀態也讓可組合項更適合重複使用。

升級經過封裝的和可重複使用的元件

View 元素通常對於自己所在位置有一定瞭解:例如在 ActivityDialogFragment 中或在另一個 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 }
        )
    }
}

在上例中,三個部分的狀態都是封裝更嚴密,耦合性更少:

  • ImageWithEnabledOverlay 只需要知道 isEnabled 的目前狀態,不需要知道 ControlPanelWithToggle 的存在或控制方式。

  • ControlPanelWithToggle 不知道 ImageWithEnabledOverlay 的存在。可能無法顯示 isEnabled,也可能有一個或多個方法將其顯示出來,而 ControlPanelWithToggle 則無需變更。

  • 對父項而言,巢狀 ImageWithEnabledOverlayControlPanelWithToggle 的深度並不重要。這些子項可以是動畫改變、調換內容,或是將內容傳遞給其他子項。

此模式被稱為 反向控制,詳情請參閱 CompositionLocal 說明文件

處理螢幕大小變更

建立回應式 View 版面配置的主要方式之一就是為不同大小的視窗提供不同的資源。雖然符合條件的資源仍然適用於確定螢幕級別的版面配置,但使用 Compose 時,在程式碼中使用一般條件邏輯即可更加輕鬆地完全變更版面配置。詳情請參閱「使用視窗大小類別」。

此外,請參閱「支援不同的螢幕大小」,瞭解 Compose 提供的建構自動調整式 UI 的相關技巧。

View 巢狀結構捲動

如果想進一步瞭解如何啟用可在捲動式檢視畫面與捲動式可組合函式之間同時使用的巢狀捲動互通性,讓兩個方向皆使用巢狀結構,請閱讀「巢狀捲動互通性」一節。

RecyclerView 中的 Compose

RecyclerView 中的可組合項自 RecyclerView 1.3.0-alpha02 版起,便一直表現良好。請務必使用 RecyclerView 1.3.0-alpha02 以上版本,才能享有這些好處。

WindowInsets 與 View 的互通性

如果畫面在同一個階層中同時含有 View 和 Compose 程式碼,您可能需要覆寫預設內嵌項目。在這種情況下,您必須明確指出哪一個應使用內嵌邊距,哪一個應忽略內嵌邊距。

舉例來說,如果最外層版面配置是 Android View 版面配置,您應在 View 系統中使用內嵌,並忽略 Compose 的內嵌。或者,如果最外層的版面配置是可組合函式,您應在 Compose 中使用內嵌,並據此為 AndroidView 可組合函式填充。

根據預設,每個 ComposeView 都會在 WindowInsetsCompat 消費層級使用所有內嵌項目。如要變更這個預設行為,請將 ComposeView.consumeWindowInsets 設為 false

詳情請參閱 WindowInsets 在 Compose 中的說明文件