Di chuyển sang Jetpack Compose

1. Giới thiệu

Compose và hệ thống Chế độ xem có thể hoạt động cùng nhau.

Trong lớp học lập trình này, bạn sẽ di chuyển các phần của màn hình thông tin chi tiết về cây trồng của Sunflower sang Compose. Chúng tôi đã tạo một bản sao của dự án để bạn có thể thử di chuyển ứng dụng thực tế sang Compose.

Khi kết thúc lớp học lập trình, bạn có thể tiếp tục di chuyển và chuyển đổi các màn hình còn lại của Sunflower nếu muốn.

Để được hỗ trợ thêm khi bạn tham gia lớp học lập trình này, hãy xem các mã sau:

Kiến thức bạn sẽ học được

Trong lớp học lập trình này, bạn sẽ tìm hiểu:

  • Những lộ trình di chuyển khác bạn có thể sử dụng
  • Cách di chuyển dần ứng dụng sang Compose
  • Cách thêm Compose vào màn hình hiện có được tạo bằng Chế độ xem
  • Cách dùng Thành phần hiển thị trong Compose
  • Cách tạo giao diện trong Compose
  • Cách kiểm thử màn hình hỗn hợp được viết trong cả Thành phần hiển thị và Compose

Điều kiện tiên quyết

Bạn cần có

2. Chiến lược di chuyển

Ngay từ đầu, Jetpack Compose được thiết kế với trọng tâm là khả năng tương tác của thành phần hiển thị. Để di chuyển sang Compose, bạn nên di chuyển lần lượt tại vị trí Compose và thành phần hiển thị cùng tồn tại trong cơ sở mã cho đến khi ứng dụng của bạn hoàn toàn chuyển sang Compose.

Chiến lược di chuyển được đề xuất như sau:

  1. Tạo màn hình mới bằng Compose
  2. Khi bạn tạo tính năng, hãy xác định những thành phần có thể tái sử dụng rồi bắt đầu tạo thư viện gồm các thành phần giao diện người dùng phổ biến
  3. Thay thế các tính năng hiện có theo từng màn hình một

Tạo màn hình mới bằng Compose

Việc sử dụng Compose để tạo các tính năng mới bao gồm toàn bộ màn hình là cách tốt nhất để tăng tỷ lệ sử dụng Compose. Với chiến lược này, bạn có thể thêm nhiều tính năng và tận dụng lợi ích của Compose mà vẫn đáp ứng được nhu cầu kinh doanh của công ty.

Tính năng mới có thể bao gồm toàn bộ màn hình, trong trường hợp đó, toàn bộ màn hình sẽ nằm trong Compose. Nếu bạn đang sử dụng chế độ điều hướng dựa trên Mảnh, tức là bạn sẽ tạo một Mảnh mới kèm nội dung của Mảnh đó trong Compose.

Bạn cũng có thể thêm tính năng mới trên màn hình hiện có. Trong trường hợp này, Thành phần hiển thị và Compose sẽ cùng tồn tại trên một màn hình. Ví dụ: giả sử tính năng bạn đang thêm là một loại thành phần hiển thị mới trong RecyclerView. Trong trường hợp đó, loại chế độ xem mới sẽ nằm trong Compose mà vẫn giữ nguyên các mục khác.

Tạo thư viện gồm các thành phần giao diện người dùng phổ biến

Khi tạo tính năng bằng Compose, bạn sẽ nhanh chóng nhận ra rằng rồi bạn cũng sẽ tạo một thư viện thành phần. Bạn nên xác định các thành phần có thể sử dụng lại để thúc đẩy quá trình sử dụng lại trên ứng dụng, nhờ đó, các thành phần dùng chung sẽ có nguồn đáng tin cậy. Các tính năng mà bạn tạo có thể phụ thuộc vào thư viện này.

Thay thế tính năng hiện có bằng Compose

Ngoài việc xây dựng tính năng mới, bạn sẽ muốn di chuyển dần các tính năng hiện có trong ứng dụng của mình sang Compose. Bạn có thể quyết định cách xử lý này, nhưng sau đây là một số đề xuất phù hợp:

  1. Màn hình đơn giản – màn hình đơn giản trong ứng dụng có một số yếu tố giao diện người dùng và có tính linh động như màn hình chào mừng, màn hình xác nhận hoặc màn hình cài đặt. Đây là những đề xuất phù hợp để chuyển sang ứng dụng Compose vì bạn chỉ cần vài dòng mã.
  2. Màn hình Chế độ xem và Compose hỗn hợp – các màn hình đã chứa một ít mã Compose là một ứng cử viên phù hợp khác vì bạn có thể tiếp tục di chuyển các phần tử trong màn hình đó theo từng mảnh. Nếu có một màn hình chỉ có cây con trong Compose thì bạn có thể tiếp tục di chuyển các phần khác của cây cho đến khi toàn bộ giao diện người dùng nằm trong Compose. Đây được gọi là phương pháp di chuyển từ dưới lên.

Phương pháp từ dưới lên để di chuyển các giao diện người dùng kết hợp của Chế độ xem và Compose sang Compose

Phương pháp trong Lớp học lập trình này

Trong lớp học lập trình này, bạn sẽ thực hiện việc di chuyển dần sang màn hình thông tin chi tiết về cây trồng của Sunflower có mục Compose và Chế độ xem hoạt động cùng nhau. Sau đó, bạn sẽ nắm rõ kiến thức đủ để tiếp tục việc di chuyển nếu muốn.

3. Thiết lập

Lấy mã

Lấy mã cho lớp học lập trình từ GitHub:

$ git clone https://github.com/android/codelab-android-compose

Ngoài ra, bạn có thể tải kho lưu trữ xuống dưới dạng tệp ZIP:

Chạy ứng dụng mẫu

Mã bạn vừa tải xuống có chứa mã dành cho tất cả lớp học lập trình Compose hiện có. Để hoàn tất lớp học lập trình này, hãy mở dự án MigrationCodelab trong Android Studio.

Trong lớp học lập trình này, bạn sẽ di chuyển màn hình thông tin chi tiết về cây trồng của Sunflower sang Compose. Có thể mở màn hình chi tiết về cây bằng cách nhấn vào một trong các cây có trong màn hình danh sách cây.

9b53216a27f911f2.png

Thiết lập dự án

Dự án được xây dựng trong nhiều nhánh git.

  • Nhánh main là điểm xuất phát của lớp học lập trình.
  • end chứa giải pháp cho lớp học lập trình này.

Bạn nên bắt đầu bằng mã trong nhánh main và làm theo hướng dẫn từng bước của lớp học lập trình theo tiến độ phù hợp với bạn.

Xuyên suốt lớp học lập trình, bạn sẽ thấy các đoạn mã bạn cần thêm vào dự án. Có lúc, bạn cũng phải xoá mã được đề cập rõ ràng trong các nhận xét trên đoạn mã.

Để nhận nhánh end bằng cách dùng git, hãy cd vào thư mục của dự án MigrationCodelab, rồi dùng lệnh:

$ git checkout end

Hoặc tải mã giải pháp từ đây:

Câu hỏi thường gặp

4. Compose trong Sunflower

Compose đã được thêm vào mã bạn tải xuống từ nhánh main. Tuy nhiên, hãy xem những điều kiện để mã này có thể hoạt động.

Nếu mở tệp build.gradle ở cấp ứng dụng, hãy xem cách tệp này nhập phần phụ thuộc Compose và cho phép Android Studio hoạt động với Compose bằng cách sử dụng cờ buildFeatures { compose true }.

app/build.gradle

android {
    //...
    kotlinOptions {
        jvmTarget = '1.8'
    }
    buildFeatures {
        //...
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion '1.3.2'
    }
}

dependencies {
    //...
    // Compose
    def composeBom = platform('androidx.compose:compose-bom:2022.10.00')
    implementation(composeBom)
    androidTestImplementation(composeBom)

    implementation "androidx.compose.runtime:runtime"
    implementation "androidx.compose.ui:ui"
    implementation "androidx.compose.foundation:foundation"
    implementation "androidx.compose.foundation:foundation-layout"
    implementation "androidx.compose.material:material"
    implementation "androidx.compose.runtime:runtime-livedata"
    implementation "androidx.compose.ui:ui-tooling"
    //...
}

Phiên bản của các phần phụ thuộc đó được xác định trong tệp build.gradle cấp dự án.

5. Xin chào Compose!

Trong màn hình thông tin chi tiết về cây, chúng ta sẽ di chuyển nội dung mô tả về cây sang Compose, đồng thời giữ nguyên cấu trúc tổng thể của màn hình.

Compose cần có Hoạt động lưu trữ hoặc Mảnh để cho thấy giao diện người dùng. Trong Sunflower, vì tất cả màn hình đều sử dụng các mảnh nên bạn sẽ dùng ComposeView: một Chế độ xem Android có thể lưu trữ nội dung trên giao diện người dùng của Compose bằng phương thức setContent.

Xoá mã XML

Hãy bắt đầu di chuyển! Mở fragment_plant_detail.xml và làm như sau:

  1. Chuyển sang Chế độ xem mã
  2. Xoá mã ConstraintLayout và 4 TextView được lồng bên trong NestedScrollView (lớp học lập trình sẽ so sánh và tham chiếu đến mã XML khi di chuyển từng mục riêng lẻ, vì vậy, bạn sẽ thấy mã có nhận xét sẽ hữu ích)
  3. Hãy thêm một ComposeView lưu trữ mã Compose thay vì với compose_view để làm id chế độ xem

fragment_plant_detail.xml

<androidx.core.widget.NestedScrollView
    android:id="@+id/plant_detail_scrollview"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipToPadding="false"
    android:paddingBottom="@dimen/fab_bottom_padding"
    app:layout_behavior="@string/appbar_scrolling_view_behavior">

    <!-- Step 2) Comment out ConstraintLayout and its children –->
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_margin="@dimen/margin_normal">

        <TextView
            android:id="@+id/plant_detail_name"
        ...

    </androidx.constraintlayout.widget.ConstraintLayout>
    <!-- End Step 2) Comment out until here –->

    <!-- Step 3) Add a ComposeView to host Compose code –->
    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</androidx.core.widget.NestedScrollView>

Thêm mã Compose

Lúc này, bạn đã sẵn sàng để bắt đầu di chuyển màn hình thông tin chi tiết về cây trồng đến Compose!

Trong suốt lớp học lập trình, bạn phải thêm mã Compose vào tệp PlantDetailDescription.kt trong thư mục plantdetail. Mở tệp này và xem cách chúng tôi đã có một phần giữ chỗ cho văn bản "Hello Compose" trong dự án.

PlantDetailDescription.kt

@Composable
fun PlantDetailDescription() {
    Surface {
        Text("Hello Compose")
    }
}

Hãy cho thấy thông tin này trên màn hình bằng cách gọi thành phần kết hợp này qua ComposeView mà chúng ta đã thêm ở bước trước. Mở PlantDetailFragment.kt.

Vì màn hình đang sử dụng tính năng liên kết dữ liệu, nên bạn có thể truy cập trực tiếp vào composeView và gọi setContent để cho thấy mã Compose trên màn hình. Hãy gọi thành phần kết hợp PlantDetailDescription bên trong MaterialTheme vì Sunflower sử dụng Material Design.

PlantDetailFragment.kt

class PlantDetailFragment : Fragment() {
    // ...
    override fun onCreateView(...): View? {
        val binding = DataBindingUtil.inflate<FragmentPlantDetailBinding>(
            inflater, R.layout.fragment_plant_detail, container, false
        ).apply {
            // ...
            composeView.setContent {
                // You're in Compose world!
                MaterialTheme {
                    PlantDetailDescription()
                }
            }
        }
        // ...
    }
}

Nếu chạy ứng dụng thì bạn có thể thấy "Hello Compose" xuất hiện trên màn hình.

a3be172fdfe6efcb.png

6. Tạo một Thành phần kết hợp từ XML

Hãy bắt đầu bằng cách di chuyển tên của cây. Chính xác hơn là TextView với mã @+id/plant_detail_name đã xoá trong fragment_plant_detail.xml. Sau đây là mã XML:

<TextView
    android:id="@+id/plant_detail_name"
    ...
    android:layout_marginStart="@dimen/margin_small"
    android:layout_marginEnd="@dimen/margin_small"
    android:gravity="center_horizontal"
    android:text="@{viewModel.plant.name}"
    android:textAppearance="?attr/textAppearanceHeadline5"
    ... />

Hãy xem cách phần này có kiểu textAppearanceHeadline5, có lề ngang là 8.dp và nằm ở giữa màn hình theo chiều ngang. Tuy nhiên, tiêu đề cần hiện được quan sát qua LiveData do PlantDetailViewModel (bắt nguồn từ lớp kho lưu trữ) hiển thị.

Khi quan sát thấy LiveData được đề cập sau đó, giả sử chúng ta có sẵn tên và tên được chuyển dưới dạng tham số vào thành phần kết hợp PlantName mới được tạo trong tệp PlantDetailDescription.kt. Thành phần kết hợp này sẽ được gọi từ thành phần kết hợp PlantDetailDescription sau.

PlantDetailDescription.kt

@Composable
private fun PlantName(name: String) {
    Text(
        text = name,
        style = MaterialTheme.typography.h5,
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = dimensionResource(R.dimen.margin_small))
            .wrapContentWidth(Alignment.CenterHorizontally)
    )
}

@Preview
@Composable
private fun PlantNamePreview() {
    MaterialTheme {
        PlantName("Apple")
    }
}

Bản xem trước:

db91b149ddbc3613.png

Trong trường hợp:

  • Kiểu của TextMaterialTheme.typography.h5, tương tự như textAppearanceHeadline5 trong mã XML.
  • Đối tượng sửa đổi sẽ trang trí Văn bản sao cho giống với phiên bản XML:
  • Đối tượng sửa đổi fillMaxWidth dùng để chiếm dung lượng tối đa hiện có. Việc này tương ứng với giá trị match_parent của thuộc tính layout_width trong mã XML.
  • Đối tượng sửa đổi padding dùng để áp dụng giá trị khoảng đệm ngang margin_small. Việc này tương ứng với việc khai báo marginStartmarginEnd trong XML. Giá trị margin_small cũng là tài nguyên phương diện hiện có được tìm nạp bằng hàm trợ giúp dimensionResource.
  • Đối tượng sửa đổi wrapContentWidth dùng để căn giữa văn bản theo chiều ngang. Việc này cũng tương tự như việc áp dụng gravity của center_horizontal trong XML.

7. ViewModel và LiveData

Bây giờ, hãy kết nối tiêu đề với màn hình. Để làm việc đó, bạn cần tải dữ liệu bằng cách sử dụng PlantDetailViewModel. Do đó, Compose tích hợp với ViewModelLiveData.

ViewModel

Vì một thực thể của PlantDetailViewModel được dùng trong Mảnh, nên chúng ta có thể truyền nó dưới dạng tham số cho PlantDetailDescription.

Mở tệp PlantDetailDescription.kt rồi thêm tham số PlantDetailViewModel vào PlantDetailDescription:

PlantDetailDescription.kt

@Composable
fun PlantDetailDescription(plantDetailViewModel: PlantDetailViewModel) {
    //...
}

Lúc này, hãy truyền thực thể của ViewModel khi gọi thành phần kết hợp này qua mảnh:

PlantDetailFragment.kt

class PlantDetailFragment : Fragment() {
    ...
    override fun onCreateView(...): View? {
        ...
        composeView.setContent {
            MaterialTheme {
                PlantDetailDescription(plantDetailViewModel)
            }
        }
    }
}

LiveData

Bằng cách này, bạn đã có quyền truy cập vào trường LiveData<Plant> của PlantDetailViewModel để lấy tên của cây.

Để quan sát LiveData qua thành phần kết hợp, hãy dùng hàm LiveData.observeAsState().

Vì giá trị do LiveData đưa ra có thể là null, nên bạn cần gói hoạt động sử dụng LiveData trong quy trình kiểm tra null. Do đó, để có thể tái sử dụng, tốt nhất hãy chia nhỏ mức tiêu thụ LiveData và nghe trong nhiều thành phần kết hợp. Hãy tạo một thành phần kết hợp mới có tên là PlantDetailContent để cho thấy thông tin về Plant.

Với những bản cập nhật này, tệp PlantDetailDescription.kt giờ đây sẽ có dạng như sau:

PlantDetailDescription.kt

@Composable
fun PlantDetailDescription(plantDetailViewModel: PlantDetailViewModel) {
    // Observes values coming from the VM's LiveData<Plant> field
    val plant by plantDetailViewModel.plant.observeAsState()

    // If plant is not null, display the content
    plant?.let {
        PlantDetailContent(it)
    }
}

@Composable
fun PlantDetailContent(plant: Plant) {
    PlantName(plant.name)
}

@Preview
@Composable
private fun PlantDetailContentPreview() {
    val plant = Plant("id", "Apple", "description", 3, 30, "")
    MaterialTheme {
        PlantDetailContent(plant)
    }
}

PlantNamePreview sẽ phản ánh thay đổi mà không cần cập nhật trực tiếp vì PlantDetailContent chỉ gọi PlantName:

4ae8fb531c2ede85.png

Lúc này, bạn đã kết nối với ViewModel để cho thấy tên cây trong Compose. Trong một số phần tiếp theo, bạn sẽ xây dựng các thành phần kết hợp còn lại rồi kết nối chúng với ViewModel theo cách tương tự.

8. Di chuyển mã XML khác

Giờ đây, bạn đã có thể dễ dàng hoàn thành những việc còn thiếu trong giao diện người dùng: thông tin tưới cây và nội dung mô tả cây cối. Khi làm theo cách tiếp cận tương tự như trước đây, bạn có thể di chuyển phần còn lại của màn hình.

Mã XML thông tin tưới cây bạn đã xoá trước đó khỏi fragment_plant_detail.xml bao gồm hai chế độ TextView có mã plant_watering_headerplant_watering.

<TextView
    android:id="@+id/plant_watering_header"
    ...
    android:layout_marginStart="@dimen/margin_small"
    android:layout_marginTop="@dimen/margin_normal"
    android:layout_marginEnd="@dimen/margin_small"
    android:gravity="center_horizontal"
    android:text="@string/watering_needs_prefix"
    android:textColor="?attr/colorAccent"
    android:textStyle="bold"
    ... />

<TextView
    android:id="@+id/plant_watering"
    ...
    android:layout_marginStart="@dimen/margin_small"
    android:layout_marginEnd="@dimen/margin_small"
    android:gravity="center_horizontal"
    app:wateringText="@{viewModel.plant.wateringInterval}"
    .../>

Tương tự như cách bạn đã làm trước đó, hãy tạo một thành phần kết hợp mới tên là PlantWatering rồi thêm thành phần kết hợp Text để cho thấy thông tin tưới cây trên màn hình:

PlantDetailDescription.kt

@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun PlantWatering(wateringInterval: Int) {
    Column(Modifier.fillMaxWidth()) {
        // Same modifier used by both Texts
        val centerWithPaddingModifier = Modifier
            .padding(horizontal = dimensionResource(R.dimen.margin_small))
            .align(Alignment.CenterHorizontally)

        val normalPadding = dimensionResource(R.dimen.margin_normal)

        Text(
            text = stringResource(R.string.watering_needs_prefix),
            color = MaterialTheme.colors.primaryVariant,
            fontWeight = FontWeight.Bold,
            modifier = centerWithPaddingModifier.padding(top = normalPadding)
        )

        val wateringIntervalText = pluralStringResource(
            R.plurals.watering_needs_suffix, wateringInterval, wateringInterval
        )
        Text(
            text = wateringIntervalText,
            modifier = centerWithPaddingModifier.padding(bottom = normalPadding)
        )
    }
}

@Preview
@Composable
private fun PlantWateringPreview() {
    MaterialTheme {
        PlantWatering(7)
    }
}

Bản xem trước:

e506690d1024be88.png

Một số điều cần lưu ý:

  • Vì thành phần kết hợp Text chia sẻ khoảng đệm ngang và trang trí căn chỉnh nên bạn có thể sử dụng lại Đối tượng sửa đổi bằng cách chỉ định đối tượng này cho một biến cục bộ (tức là centerWithPaddingModifier). Vì đối tượng sửa đổi là đối tượng Kotlin thông thường, nên bạn có thể thực hiện việc này.
  • MaterialTheme của Compose không có kết quả khớp chính xác với colorAccent được sử dụng trong plant_watering_header. Lúc này, hãy dùng MaterialTheme.colors.primaryVariant mà bạn sẽ cải thiện trong phần giao diện khả năng tương tác.
  • Trong Compose 1. 2.1, để dùng pluralStringResource, bạn phải chọn dùng ExperimentalComposeUiApi. Trong một phiên bản tương lai của Compose, tính năng này có thể không cần thiết nữa.

Hãy kết nối tất cả các phần với nhau và cũng gọi PlantWatering từ PlantDetailContent. Mã ConstraintLayout XML chúng ta xoá ở phần đầu có lề 16.dp chúng ta cần đưa vào mã Compose.

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_margin="@dimen/margin_normal">

Trong PlantDetailContent, hãy tạo Column để cho thấy cả tên và thông tin tưới nước, đồng thời dùng làm khoảng đệm. Ngoài ra, để màu nền và màu văn bản được dùng đều phù hợp, hãy thêm Surface để xử lý vấn đề này.

PlantDetailDescription.kt

@Composable
fun PlantDetailContent(plant: Plant) {
    Surface {
        Column(Modifier.padding(dimensionResource(R.dimen.margin_normal))) {
            PlantName(plant.name)
            PlantWatering(plant.wateringInterval)
        }
    }
}

Nếu làm mới bản xem trước, bạn sẽ thấy:

311e08a065f58cd3.png

9. Chế độ xem trong mã Compose

Bây giờ, hãy di chuyển phần mô tả cây. Mã trong fragment_plant_detail.xml có một TextView với app:renderHtml="@{viewModel.plant.description}" để cho XML biết cần cho thấy văn bản nào trên màn hình. renderHtml là phương thức điều hợp liên kết (binding adapter) có trong tệp PlantDetailBindingAdapters.kt. Cách triển khai sử dụng HtmlCompat.fromHtml để đặt văn bản trên TextView!

Tuy nhiên, Compose hiện không hỗ trợ các lớp Spanned cũng như không cho thấy văn bản có định dạng HTML. Do đó, chúng ta cần dùng TextView từ hệ thống Chế độ xem trong mã Compose để bỏ qua giới hạn này.

Vì Compose chưa thể kết xuất mã HTML nên bạn sẽ tạo TextView theo phương thức lập trình để thực hiện chính xác việc đó bằng API AndroidView.

AndroidView cho phép bạn tạo View trong hàm lambda factory của nó. Mã này cũng cung cấp lambda update được gọi khi Khung hiển thị đã được tăng cường và trong các quy trình kết hợp lại tiếp theo.

Hãy thực hiện việc này bằng cách tạo một thành phần kết hợp PlantDescription mới. Thành phần kết hợp này gọi AndroidView để tạo TextView trong hàm lambda factory. Trong hàm lambda factory, hãy khởi chạy TextView để cho thấy văn bản ở định dạng HTML, sau đó đặt movementMethod thành một phiên bản của LinkMovementMethod. Cuối cùng, trong hàm lambda update, hãy đặt văn bản của TextView thành htmlDescription.

PlantDetailDescription.kt

@Composable
private fun PlantDescription(description: String) {
    // Remembers the HTML formatted description. Re-executes on a new description
    val htmlDescription = remember(description) {
        HtmlCompat.fromHtml(description, HtmlCompat.FROM_HTML_MODE_COMPACT)
    }

    // Displays the TextView on the screen and updates with the HTML description when inflated
    // Updates to htmlDescription will make AndroidView recompose and update the text
    AndroidView(
        factory = { context ->
            TextView(context).apply {
                movementMethod = LinkMovementMethod.getInstance()
            }
        },
        update = {
            it.text = htmlDescription
        }
    )
}

@Preview
@Composable
private fun PlantDescriptionPreview() {
    MaterialTheme {
        PlantDescription("HTML<br><br>description")
    }
}

Bản xem trước:

12928a361edc390e.png

Lưu ý htmlDescription ghi nhớ mô tả HTML cho description nhất định được chuyển dưới dạng tham số. Nếu tham số description thay đổi thì mã htmlDescription bên trong remember sẽ thực thi lại.

Do đó, lệnh gọi lại cập nhật AndroidView sẽ khởi tạo lại nếu htmlDescription thay đổi. Mọi trạng thái được đọc bên trong lambda update đều dẫn đến quá trình kết hợp lại.

Hãy thêm PlantDescription vào thành phần kết hợp PlantDetailContent rồi thay đổi mã xem trước để cho thấy nội dung mô tả HTML:

PlantDetailDescription.kt

@Composable
fun PlantDetailContent(plant: Plant) {
    Surface {
        Column(Modifier.padding(dimensionResource(R.dimen.margin_normal))) {
            PlantName(plant.name)
            PlantWatering(plant.wateringInterval)
            PlantDescription(plant.description)
        }
    }
}

@Preview
@Composable
private fun PlantDetailContentPreview() {
    val plant = Plant("id", "Apple", "HTML<br><br>description", 3, 30, "")
    MaterialTheme {
        PlantDetailContent(plant)
    }
}

Bản xem trước:

38f43bf79290a9d7.png

Lúc này, bạn đã di chuyển tất cả nội dung bên trong ConstraintLayout gốc sang Compose. Bạn có thể chạy ứng dụng để kiểm tra xem ứng dụng có hoạt động như mong đợi không.

c7021c18eb8b4d4e.gif

10. Phương thức ViewCompositionStrategy

Compose sẽ huỷ bỏ Cấu trúc (Composition) bất cứ khi nào ComposeView bị tách khỏi cửa sổ. Điều này là ngoài mong muốn khi ComposeView được dùng trong mảnh vì 2 lý do:

  • Cấu trúc phải tuân thủ vòng đời chế độ xem của mảnh đối với loại View giao diện người dùng của Compose để lưu trạng thái.
  • Khi quá trình chuyển đổi xảy ra, ComposeView cơ bản sẽ ở trạng thái tách rời. Tuy nhiên, các thành phần trên giao diện người dùng Compose vẫn sẽ xuất hiện trong quá trình chuyển đổi này.

Để sửa đổi hành vi này, hãy gọi setViewCompositionStrategy cùng ViewCompositionStrategy thích hợp để tuân thủ vòng đời chế độ xem của mảnh. Cụ thể là bạn nên sử dụng chiến lược DisposeOnViewTreeLifecycleDestroyed để loại bỏ Cấu trúc khi LifecycleOwner của mảnh bị huỷ bỏ.

PlantDetailFragment có chuyển đổi vào (enter) và thoát (exit) (xem nav_garden.xml để biết thêm thông tin) nên chúng ta sẽ dùng loại View trong Compose vào lúc khác, điều cần làm lúc này là đảm bảo ComposeView sử dụng chiến lược DisposeOnViewTreeLifecycleDestroyed. Tuy nhiên, phương pháp hay nhất là luôn đặt chiến lược này khi sử dụng ComposeView trong mảnh.

PlantDetailFragment.kt

import androidx.compose.ui.platform.ViewCompositionStrategy
...

class PlantDetailFragment : Fragment() {
    ...
    override fun onCreateView(...): View? {
        val binding = DataBindingUtil.inflate<FragmentPlantDetailBinding>(
            inflater, R.layout.fragment_plant_detail, container, false
        ).apply {
            ...
            composeView.apply {
                // Dispose the Composition when the view's LifecycleOwner
                // is destroyed
                setViewCompositionStrategy(
                    ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
                )
                setContent {
                    MaterialTheme {
                        PlantDetailDescription(plantDetailViewModel)
                    }
                }
            }
        }
        ...
    }
}

11. Tuỳ chỉnh giao diện Material

Chúng ta đã di chuyển nội dung văn bản của thông tin chi tiết về cây sang Compose. Tuy nhiên, bạn có thể nhận thấy Compose không sử dụng đúng màu giao diện. Tên cây đang là màu tím, trong khi đáng ra phải là màu xanh lục.

Để sử dụng đúng màu giao diện, bạn cần tuỳ chỉnh MaterialTheme bằng cách xác định giao diện riêng và chọn màu sắc.

Tuỳ chỉnh MaterialTheme

Để tạo giao diện riêng, hãy mở tệp Theme.kt trong gói theme. Theme.kt xác định một thành phần kết hợp có tên là SunflowerTheme chấp nhận lambda nội dung và truyền nội dung đó xuống MaterialTheme.

Thành phần này chưa thực hiện bất kỳ chức năng thú vị nào. Bạn sẽ tuỳ chỉnh thành phần này ở bước tiếp theo.

Theme.kt

import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable

@Composable
fun SunflowerTheme(
    content: @Composable () -> Unit
) {
    MaterialTheme(content = content)
}

MaterialTheme hỗ trợ bạn tuỳ chỉnh màu sắc, kiểu chữ và hình dạng. Lúc này, hãy tiếp tục và tuỳ chỉnh màu sắc bằng cách chọn màu sắc tương tự trong giao diện của Sunflower View. SunflowerTheme cũng có thể chấp nhận tham số boolean có tên darkTheme. Tham số này sẽ mặc định là true nếu hệ thống ở chế độ tối, nếu không thì là false. Bằng cách sử dụng tham số này, chúng ta có thể truyền các giá trị màu phù hợp đến MaterialTheme để khớp với giao diện hệ thống hiện được áp dụng.

Theme.kt

@Composable
fun SunflowerTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val lightColors  = lightColors(
        primary = colorResource(id = R.color.sunflower_green_500),
        primaryVariant = colorResource(id = R.color.sunflower_green_700),
        secondary = colorResource(id = R.color.sunflower_yellow_500),
        background = colorResource(id = R.color.sunflower_green_500),
        onPrimary = colorResource(id = R.color.sunflower_black),
        onSecondary = colorResource(id = R.color.sunflower_black),
    )
    val darkColors  = darkColors(
        primary = colorResource(id = R.color.sunflower_green_100),
        primaryVariant = colorResource(id = R.color.sunflower_green_200),
        secondary = colorResource(id = R.color.sunflower_yellow_300),
        onPrimary = colorResource(id = R.color.sunflower_black),
        onSecondary = colorResource(id = R.color.sunflower_black),
        onBackground = colorResource(id = R.color.sunflower_black),
        surface = colorResource(id = R.color.sunflower_green_100_8pc_over_surface),
        onSurface = colorResource(id = R.color.sunflower_white),
    )
    val colors = if (darkTheme) darkColors else lightColors
    MaterialTheme(
        colors = colors,
        content = content
    )
}

Để sử dụng loại này, hãy thay thế MaterialTheme được sử dụng cho SunflowerTheme. Ví dụ: trong PlantDetailFragment:

PlantDetailFragment.kt

class PlantDetailFragment : Fragment() {
    ...
    composeView.apply {
        ...
        setContent {
            SunflowerTheme {
                PlantDetailDescription(plantDetailViewModel)
            }
        }
    }
}

Và tất cả thành phần kết hợp xem trước trong tệp PlantDetailDescription.kt:

PlantDetailDescription.kt

@Preview
@Composable
private fun PlantDetailContentPreview() {
    val plant = Plant("id", "Apple", "HTML<br><br>description", 3, 30, "")
    SunflowerTheme {
        PlantDetailContent(plant)
    }
}

@Preview
@Composable
private fun PlantNamePreview() {
    SunflowerTheme {
        PlantName("Apple")
    }
}

@Preview
@Composable
private fun PlantWateringPreview() {
    SunflowerTheme {
        PlantWatering(7)
    }
}

@Preview
@Composable
private fun PlantDescriptionPreview() {
    SunflowerTheme {
        PlantDescription("HTML<br><br>description")
    }
}

Như bạn có thể thấy trong bản xem trước, màu sắc hiện đã khớp với màu của giao diện Sunflower.

9b0953b7bb00a63d.png

Bạn cũng có thể xem trước giao diện người dùng trong giao diện tối bằng cách tạo một hàm mới rồi chuyển Configuration.UI_MODE_NIGHT_YES đến uiMode của bản xem trước:

import android.content.res.Configuration
...

@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PlantDetailContentDarkPreview() {
    val plant = Plant("id", "Apple", "HTML<br><br>description", 3, 30, "")
    SunflowerTheme {
        PlantDetailContent(plant)
    }
}

Bản xem trước:

51e24f4b9a7caf1.png

Nếu bạn chạy ứng dụng thì ứng dụng sẽ hoạt động y như trước khi di chuyển, đối với cả giao diện sáng lẫn tối:

438d2dd9f8acac39.gif

12. Thử nghiệm

Sau khi di chuyển các phần của màn hình thông tin chi tiết về cây sang Compose, bạn phải kiểm thử để đảm bảo không có bất cứ vấn đề gì.

Trong Sunflower, PlantDetailFragmentTest nằm trong thư mục androidTest sẽ kiểm tra một số chức năng của ứng dụng. Hãy mở tệp và xem mã hiện tại:

  • testPlantName kiểm tra tên của cây trên màn hình
  • testShareTextIntent kiểm tra để đảm bảo ý định phù hợp được kích hoạt sau khi nhấn vào nút chia sẻ

Khi một hoạt động hoặc mảnh sử dụng Compose, thay vì dùng ActivityScenarioRule, bạn phải dùng createAndroidComposeRule tích hợp ActivityScenarioRule với ComposeTestRule để có thể kiểm thử mã Compose.

Trong PlantDetailFragmentTest, hãy thay thế cách sử dụng ActivityScenarioRule bằng createAndroidComposeRule. Khi cần có quy tắc hoạt động để định cấu hình kiểm thử, hãy dùng thuộc tính activityRule của createAndroidComposeRule theo cách sau:

@RunWith(AndroidJUnit4::class)
class PlantDetailFragmentTest {

    @Rule
    @JvmField
    val composeTestRule = createAndroidComposeRule<GardenActivity>()

    ...

    @Before
    fun jumpToPlantDetailFragment() {
        populateDatabase()

        composeTestRule.activityRule.scenario.onActivity { gardenActivity ->
            activity = gardenActivity

            val bundle = Bundle().apply { putString("plantId", "malus-pumila") }
            findNavController(activity, R.id.nav_host).navigate(R.id.plant_detail_fragment, bundle)
        }
    }

    ...
}

Nếu bạn chạy thử nghiệm, testPlantName sẽ không thành công! testPlantName kiểm tra để tìm một TextView hiển thị trên màn hình. Tuy nhiên, bạn đã di chuyển phần đó trong giao diện người dùng sang Compose. Do đó, bạn cần phải sử dụng tính năng Xác nhận Compose:

@Test
fun testPlantName() {
    composeTestRule.onNodeWithText("Apple").assertIsDisplayed()
}

Nếu chạy kiểm thử, bạn sẽ thấy tất cả đều đạt.

b743660b5e840b06.png

13. Xin chúc mừng

Xin chúc mừng, bạn đã hoàn tất thành công lớp học lập trình này!

Nhánh compose của dự án github Sunflower ban đầu sẽ di chuyển toàn bộ màn hình thông tin chi tiết về cây sang Compose. Ngoài những việc đã hoàn thành trong lớp học lập trình này, bạn còn cần mô phỏng hành vi của CollapsingThanhLayout. Việc này bao gồm:

  • Tải hình ảnh bằng Compose
  • Ảnh động
  • Xử lý phương diện tốt hơn
  • Và nhiều kiến thức khác!

Nội dung tiếp theo

Hãy tham khảo các lớp học lập trình khác trên Lộ trình học Compose.

Tài liệu đọc thêm