Di chuyển từ Navigation 2 sang Navigation 3

Để di chuyển ứng dụng từ Navigation 2 sang Navigation 3, hãy làm theo các bước sau:

  1. Thêm các phần phụ thuộc của Navigation 3.
  2. Cập nhật tuyến đường điều hướng để triển khai giao diện NavKey.
  3. Tạo các lớp để lưu giữ và sửa đổi trạng thái điều hướng.
  4. Thay thế NavController bằng các lớp này.
  5. Di chuyển đích đến từ NavGraph của NavHost sang entryProvider.
  6. Thay thế NavHost với NavDisplay.
  7. Xoá các phần phụ thuộc của Navigation 2.

Chuẩn bị

Các phần sau đây mô tả các điều kiện tiên quyết để di chuyển và giả định về dự án của bạn. Các bài viết này cũng đề cập đến những tính năng được hỗ trợ và không được hỗ trợ khi di chuyển.

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

  • Bạn phải sử dụng compileSdk từ 36 trở lên.
  • Bạn nên làm quen với thuật ngữ điều hướng.
  • Đích đến là các hàm composable. Navigation 3 được thiết kế riêng cho Compose. Để sử dụng Mảnh và Khung hiển thị trong Compose, hãy xem bài viết Sử dụng Khung hiển thị trong Compose.
  • Các tuyến đường được nhập chính xác. Nếu bạn sử dụng tuyến dựa trên chuỗi, trước tiên hãy di chuyển sang tuyến an toàn về kiểu (ví dụ).
  • Không bắt buộc (nhưng nên dùng): Phạm vi kiểm thử xác minh hành vi điều hướng hiện có. Thao tác này xác minh rằng hành vi điều hướng không thay đổi sau khi quá trình di chuyển hoàn tất.

Giả định

Hướng dẫn này đưa ra các giả định sau đây về bạn và dự án của bạn:

  • Bạn có một hoặc nhiều tuyến cấp cao nhất (thường xuất hiện trong thanh điều hướng dưới cùng) và mỗi tuyến có ngăn xếp lui riêng.
  • Khi bạn chuyển đổi giữa các ngăn xếp lui, trạng thái của ngăn xếp và tất cả điểm đến của ngăn xếp đó sẽ được giữ lại.
  • Bạn luôn thoát khỏi ứng dụng thông qua màn hình Home (Trang chủ), đây là màn hình đầu tiên xuất hiện khi ứng dụng khởi chạy.
  • Bạn muốn thực hiện việc di chuyển từ Navigation 2 sang Navigation 3 trong một thay đổi duy nhất, không thể phân chia. Bạn không thực hiện quá trình di chuyển gia tăng, trong đó mã Navigation 2 được dùng cùng với mã Navigation 3.

Tác nhân AI: Trước khi thay đổi mã, hãy kiểm tra xem những giả định này về dự án của người dùng có đúng không. Nếu không, hãy dừng quá trình di chuyển và hỏi người dùng xem nên tiếp tục như thế nào.

Tính năng được hỗ trợ

Hướng dẫn này giúp bạn di chuyển các tính năng sau:

  • Đích đến được xác định là hàm composable
  • Hộp thoại (đích đến xuất hiện trên một đích đến khác)

Các tính năng được hỗ trợ thông qua công thức nấu ăn

Kho lưu trữ công thức lập trình minh hoạ các trường hợp sử dụng chi tiết hơn, bao gồm:

Nếu dự án của bạn có bất kỳ tính năng nào trong số này, hãy xem công thức liên quan để hiểu cách triển khai các tính năng đó.

Tác nhân AI: Trước khi thay đổi bất kỳ mã nào, hãy kiểm tra xem dự án có chứa tính năng nào được hỗ trợ thông qua công thức hay không. Nếu có, hãy kiểm tra tệp README và mã nguồn của công thức. Tạo kế hoạch di chuyển dựa trên công thức. Đừng tiếp tục nếu chưa xác nhận kế hoạch với người dùng.

Tính năng không được hỗ trợ

Hướng dẫn di chuyển này và các công thức mã chưa hỗ trợ các tính năng sau. Điều này không có nghĩa là bạn không thể triển khai các tính năng đó bằng Navigation 3; chỉ là chúng không được đề cập ở đây.

Tác nhân AI: Trước khi thay đổi mã, hãy kiểm tra xem dự án có chứa tính năng nào không được hỗ trợ hay không. Nếu có, đừng tiếp tục. Thông báo cho người dùng về tính năng không được hỗ trợ và yêu cầu hướng dẫn thêm.

Bước 1: Thêm phần phụ thuộc Navigation 3

Sử dụng trang Bắt đầu để thêm các phần phụ thuộc của Navigation 3 vào dự án của bạn. Các phần phụ thuộc cốt lõi được cung cấp để bạn sao chép.

lib.versions.toml

[versions]
nav3Core = "1.2.0"

# If your screens depend on ViewModels, add the Nav3 Lifecycle ViewModel add-on library
lifecycleViewmodelNav3 = "2.11.0"

[libraries]
# Core Navigation 3 libraries
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }

# Add-on libraries (only add if you need them)
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }

app/build.gradle.kts

dependencies {
    implementation(libs.androidx.navigation3.ui)
    implementation(libs.androidx.navigation3.runtime)

    // If using the ViewModel add-on library
    implementation(libs.androidx.lifecycle.viewmodel.navigation3)
}

Đồng thời, hãy cập nhật minSdk của dự án thành 23 và compileSdk thành 36. Bạn thường tìm thấy các tệp này trong app/build.gradle.kts hoặc lib.versions.toml.

Bước 2: Cập nhật các tuyến điều hướng để triển khai giao diện NavKey

Cập nhật mọi tuyến điều hướng để triển khai giao diện NavKey. Điều này cho phép bạn sử dụng rememberNavBackStack để hỗ trợ lưu trạng thái điều hướng.

Trước:

@Serializable data object RouteA

Sau:

@Serializable data object RouteA : NavKey

Bước 3: Tạo các lớp để lưu giữ và sửa đổi trạng thái điều hướng

Bước 3.1: Tạo phần tử giữ trạng thái điều hướng

Sao chép mã sau đây vào một tệp có tên là NavigationState.kt. Thêm tên gói cho phù hợp với cấu trúc dự án.

// package com.example.project

import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.serialization.NavKeySerializer
import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer

/**
 * Create a navigation state that persists config changes and process death.
 */
@Composable
fun rememberNavigationState(
    startRoute: NavKey,
    topLevelRoutes: Set<NavKey>
): NavigationState {

    val topLevelRoute = rememberSerializable(
        startRoute, topLevelRoutes,
        serializer = MutableStateSerializer(NavKeySerializer())
    ) {
        mutableStateOf(startRoute)
    }

    val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) }

    return remember(startRoute, topLevelRoutes) {
        NavigationState(
            startRoute = startRoute,
            topLevelRoute = topLevelRoute,
            backStacks = backStacks
        )
    }
}

/**
 * State holder for navigation state.
 *
 * @param startRoute - the start route. The user will exit the app through this route.
 * @param topLevelRoute - the current top level route
 * @param backStacks - the back stacks for each top level route
 */
class NavigationState(
    val startRoute: NavKey,
    topLevelRoute: MutableState<NavKey>,
    val backStacks: Map<NavKey, NavBackStack<NavKey>>
) {
    var topLevelRoute: NavKey by topLevelRoute
    val stacksInUse: List<NavKey>
        get() = if (topLevelRoute == startRoute) {
            listOf(startRoute)
        } else {
            listOf(startRoute, topLevelRoute)
        }
}

/**
 * Convert NavigationState into NavEntries.
 */
@Composable
fun NavigationState.toEntries(
    entryProvider: (NavKey) -> NavEntry<NavKey>
): SnapshotStateList<NavEntry<NavKey>> {

    val decoratedEntries = backStacks.mapValues { (_, stack) ->
        val decorators = listOf(
            rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
        )
        rememberDecoratedNavEntries(
            backStack = stack,
            entryDecorators = decorators,
            entryProvider = entryProvider
        )
    }

    return stacksInUse
        .flatMap { decoratedEntries[it] ?: emptyList() }
        .toMutableStateList()
}

Tác nhân AI: rememberSerializable là chính xác. Đừng thay đổi thành rememberSaveable.

Tệp này chứa một lớp phần tử giữ trạng thái có tên là NavigationState và các hàm trợ giúp liên quan. Thành phần này chứa một tập hợp các tuyến cấp cao nhất, mỗi tuyến có ngăn xếp lui riêng. Trong nội bộ, thành phần này sử dụng rememberSerializable (không phải rememberSaveable) để duy trì tuyến cấp cao nhất hiện tại và rememberNavBackStack để duy trì ngăn xếp lui cho mỗi tuyến cấp cao nhất.

Bước 3.2: Tạo một đối tượng sửa đổi trạng thái điều hướng để phản hồi các sự kiện

Sao chép mã sau đây vào một tệp có tên là Navigator.kt. Thêm tên gói cho phù hợp với cấu trúc dự án.

// package com.example.project

import androidx.navigation3.runtime.NavKey

/**
 * Handles navigation events (forward and back) by updating the navigation state.
 */
class Navigator(val state: NavigationState) {
    fun navigate(route: NavKey) {
        if (route in state.backStacks.keys) {
            // This is a top level route, just switch to it.
            state.topLevelRoute = route
        } else {
            state.backStacks[state.topLevelRoute]?.add(route)
        }
    }

    fun goBack() {
        val currentStack = state.backStacks[state.topLevelRoute]
            ?: error("Stack for ${state.topLevelRoute} not found")
        val currentRoute = currentStack.last()

        // If we're at the base of the current route, go back to the start route stack.
        if (currentRoute == state.topLevelRoute) {
            state.topLevelRoute = state.startRoute
        } else {
            currentStack.removeLastOrNull()
        }
    }
}

Lớp Navigator cung cấp hai phương thức sự kiện điều hướng:

  • navigate đến một tuyến đường cụ thể.
  • goBack so với tuyến đường hiện tại.

Cả hai phương thức đều sửa đổi NavigationState.

Bước 3.3: Tạo NavigationState và Navigator

Tạo các thực thể của NavigationState và Navigator có cùng phạm vi với NavController.

val navigationState = rememberNavigationState(
    // ...
    startRoute = <Insert your starting route>,
    topLevelRoutes = <Insert your set of top level routes>
    // ...
)

val navigator = remember { Navigator(navigationState) }

Bước 4: Thay thế NavController

Thay thế các phương thức sự kiện điều hướng NavController bằng các phương thức tương đương Navigator.

Trường hoặc phương thức NavController

Tương đương Navigator

navigate()

navigate()

popBackStack()

goBack()

previousBackStackEntry.savedStateHandle.set()

ResultEventBus.sendResult()

Thay thế các trường NavController bằng các trường NavigationState.

Trường hoặc phương thức NavController

Tương đương NavigationState

currentBackStack

backStacks[topLevelRoute]

currentBackStackEntry

currentBackStackEntryAsState()

currentBackStackEntryFlow

currentDestination

backStacks[topLevelRoute].last()

currentBackStackEntry.savedStateHandle.getLiveData()

currentBackStackEntry.savedStateHandle.getStateFlow()

ResultEffect (dựa trên sự kiện)

ResultEventBus.conflateAsState() (theo trạng thái)

Lấy tuyến cấp cao nhất: Di chuyển lên hệ phân cấp từ mục nhập ngăn xếp lui hiện tại để tìm tuyến đó.

topLevelRoute

Sử dụng NavigationState.topLevelRoute để xác định mục hiện đang được chọn trong thanh điều hướng.

Trước:

// ...
val isSelected = navController.currentBackStackEntryAsState().value?.destination.isRouteInHierarchy(key::class)
// ...

fun NavDestination?.isRouteInHierarchy(route: KClass<*>) =
    this?.hierarchy?.any {
        it.hasRoute(route)
    } ?: false

Sau:

val isSelected = key == navigationState.topLevelRoute

Xác minh rằng bạn đã xoá tất cả tệp đối chiếu đến NavController, bao gồm cả mọi tệp nhập.

Bước 4.1 Di chuyển logic nhận biết vòng đời

Trong Navigation 2, NavBackStackEntry triển khai LifecycleOwner, cho phép bạn nghe các sự kiện trong vòng đời hoặc thu thập luồng theo cách nhận biết vòng đời bằng cách sử dụng navController.currentBackStackEntry.

Trong Điều hướng 3, NavDisplay cung cấp một LifecycleOwner trong phạm vi điểm truy cập thông qua LocalLifecycleOwner.current cho nội dung composable của mỗi đích đến. Xem phần Vòng đời của đích đến để biết thêm thông tin.

Bạn nên thực hiện các thao tác nhận biết vòng đời ngay trong nội dung composable của đích đến bằng cách tham chiếu LocalLifecycleOwner.current.

Ví dụ: nếu bạn thu thập một luồng theo cách nhận biết vòng đời bằng cách sử dụng mục nhập ngăn xếp lui:

Trước:

// In your destination screen or host
val lifecycleOwner = navController.currentBackStackEntry!!
val state by flow.collectAsStateWithLifecycle(lifecycleOwner = lifecycleOwner)

Sau:

// Inside the destination composable
val state by flow.collectAsStateWithLifecycle()

Bước 4.2 Di chuyển kết quả truyền dữ liệu

Trong Navigation 2, các đích đến sẽ trả kết quả về các đích đến trước đó bằng cách sử dụng SavedStateHandle trên NavBackStackEntry. Vì SavedStateHandle được hỗ trợ bởi trạng thái thực thể đã lưu, nên dữ liệu được trả về sẽ tự động tồn tại sau khi bị buộc tắt.

Trước:

// Sender destination:
navController.previousBackStackEntry?.savedStateHandle?.set("contact_key", contact)
navController.popBackStack()

// Receiver destination:
val lifecycleOwner = LocalLifecycleOwner.current
navController.currentBackStackEntry?.savedStateHandle
    ?.getLiveData<Contact>("contact_key")
    ?.observe(lifecycleOwner) { contact ->
        viewModel.onRecipientSelected(contact)
    }

Trong Navigation 3, hãy thêm rememberResultEventBusNavEntryDecorator() vào NavDisplay.entryDecorators. Trong tệp ánh xạ entryProvider của đích gửi, hãy truy xuất LocalResultEventBus.current và gọi sendResult(). Trong đích đến nhận, hãy sử dụng ResultEffect để chuyển tiếp sự kiện đến ViewModel hoặc kích hoạt một tác dụng phụ.

Sau:

// Sender destination (in entryProvider):
entry<ContactPickerRoute> {
    val resultBus = LocalResultEventBus.current

    ContactPickerScreen(
        onContactSelected = { contact ->
            resultBus.sendResult<Contact>(result = contact)
            navigator.goBack()
        }
    )
}

// Receiver destination:
@Composable
fun ComposeMessageScreen(viewModel: ComposeMessageViewModel = viewModel()) {
    ResultEffect<Contact> { contact ->
        viewModel.onRecipientSelected(contact)
    }

    ComposeMessageContent(recipient = viewModel.recipient)
}

Để quan sát dựa trên trạng thái, bạn có thể gọi resultBus.conflateAsState(). Để biết thêm thông tin, hãy xem phần Trả về kết quả.

Bước 5: Di chuyển đích đến từ NavGraph của NavHost sang entryProvider

Trong Navigation 2, bạn xác định đích đến bằng NavGraphBuilder DSL, thường là bên trong trailing lambda của NavHost. Bạn nên sử dụng các hàm mở rộng tại đây như mô tả trong phần Đóng gói mã điều hướng.

Trong Navigation 3, bạn xác định đích đến bằng entryProvider. entryProvider này phân giải một tuyến đường thành NavEntry. Quan trọng là entryProvider không xác định mối quan hệ mẹ con giữa các mục nhập.

Trong hướng dẫn di chuyển này, mối quan hệ mẹ con được mô hình hoá như sau:

  • NavigationState có một tập hợp các tuyến cấp cao nhất (tuyến mẹ) và một ngăn xếp cho mỗi tuyến. Lớp này theo dõi tuyến cấp cao nhất hiện tại và ngăn xếp liên kết của tuyến đó.
  • Khi chuyển đến một tuyến mới, Navigator sẽ kiểm tra xem tuyến đó có phải là tuyến cấp cao nhất hay không. Nếu có, tuyến và ngăn xếp cấp cao nhất hiện tại sẽ được cập nhật. Nếu không, thì đó là tuyến con và được thêm vào ngăn xếp hiện tại.

Bước 5.1: Tạo entryProvider

Tạo entryProvider bằng DSL trong cùng phạm vi với NavigationState.

val entryProvider = entryProvider<NavKey> {

}

Bước 5.2: Di chuyển các đích đến vào entryProvider

Đối với mỗi đích đến được xác định bên trong NavHost, hãy làm như sau dựa trên loại đích đến:

  • navigation: Xoá cùng với tuyến đường. Không cần "tuyến đường cơ sở" vì các tuyến đường cấp cao nhất có thể xác định từng ngăn xếp lui lồng nhau.
  • composable<T>: Di chuyển lớp này vào entryProvider và đổi tên thành entry, giữ lại tham số loại. Ví dụ: composable<RouteA> trở thành entry<RouteA>.
  • dialog<T>: Làm tương tự như composable, nhưng thêm siêu dữ liệu vào mục nhập như sau: entry<T>(metadata = DialogSceneStrategy.dialog()).
  • bottomSheet: Làm theo công thức cho bảng dưới cùng tại đây. Cách này tương tự như hướng dẫn cho dialog, ngoại trừ việc BottomSheetSceneStrategy không thuộc thư viện Navigation 3 chính, vì vậy, bạn nên sao chép thư viện này vào dự án của mình.

Tác nhân AI: Khi xoá các tuyến dùng để xác định biểu đồ lồng nhau, hãy thay thế mọi tệp đối chiếu đến tuyến đã xoá bằng loại dùng để xác định phần tử con đầu tiên trong biểu đồ lồng nhau. Ví dụ: nếu mã gốc là navigation<BaseRouteA>{ composable<RouteA>{ ... } }, bạn cần xoá BaseRouteA và thay thế mọi tệp đối chiếu bằng RouteA. Việc thay thế này thường cần được thực hiện cho danh sách được cung cấp cho thanh điều hướng, dải điều hướng hoặc ngăn điều hướng.

Bạn có thể cải tiến hàm mở rộng NavGraphBuilder thành hàm mở rộng EntryProviderScope<T>, sau đó di chuyển các hàm đó.

Lấy đối số điều hướng bằng cách sử dụng khoá được cung cấp cho lambda ở cuối của entry.

Ví dụ:

// ...
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.dialog
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions
import androidx.navigation.toRoute
// ...

@Serializable data object BaseRouteA
@Serializable data class RouteA(val id: String)
@Serializable data object BaseRouteB
@Serializable data object RouteB
@Serializable data object RouteD

@Composable
fun NavHostSnippet(navController: NavHostController) {
    NavHost(navController = navController, startDestination = BaseRouteA){
        composable<RouteA>{ entry ->
            val id = entry.toRoute<RouteA>().id
            ScreenA(title = "Screen has ID: $id")
        }
        featureBSection()
        dialog<RouteD>{ ScreenD() }
    }
}

fun NavGraphBuilder.featureBSection() {
    navigation<BaseRouteB>(startDestination = RouteB) {
        composable<RouteB> { ScreenB() }
    }
}

trở thành:

// ...
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.scene.DialogSceneStrategy
// ...

@Serializable data class RouteA(val id: String) : NavKey
@Serializable data object RouteB : NavKey
@Serializable data object RouteD : NavKey

val entryProvider = entryProvider {
    entry<RouteA>{ key -> ScreenA(title = "Screen has ID: ${key.id}") }
    featureBSection()
    entry<RouteD>(metadata = DialogSceneStrategy.dialog()){ ScreenD() }
}

fun EntryProviderScope<NavKey>.featureBSection() {
    entry<RouteB> { ScreenB() }
}

Bước 6: Thay thế NavHost bằng NavDisplay

Thay thế NavHost với NavDisplay.

  • Xoá NavHost và thay bằng NavDisplay.
  • Chỉ định entries = navigationState.toEntries(entryProvider) làm tham số. Thao tác này sẽ chuyển đổi trạng thái điều hướng thành các mục mà NavDisplay hiển thị bằng entryProvider.
  • Kết nối NavDisplay.onBack với navigator.goBack(). Điều này khiến navigator cập nhật trạng thái điều hướng khi trình xử lý quay lại tích hợp sẵn của NavDisplay hoàn tất.
  • Nếu bạn có đích đến của hộp thoại, hãy thêm DialogSceneStrategy vào tham số sceneStrategies của NavDisplay.

Ví dụ:

NavDisplay(
    entries = navigationState.toEntries(entryProvider),
    onBack = { navigator.goBack() },
    sceneStrategies = remember { listOf(DialogSceneStrategy()) }
)

Bước 7: Di chuyển đường liên kết sâu

Trong Navigation 2, đường liên kết sâu được xác định trực tiếp bên trong biểu đồ điều hướng bằng cách sử dụng tham số deepLinks của đích đến.

Trong Navigation 3, đường liên kết sâu được quản lý độc lập với giao diện người dùng điều hướng. Bạn xác định DeepLinkMatcher và so khớp các yêu cầu đến trong Hoạt động để tạo ngăn xếp lui ban đầu.

Trước:

Trong Navigation 2, bạn có thể đã xác định một đường liên kết sâu như sau:

Sau:

Trong Navigation 3, bạn xác định một UriDeepLinkMatcher cho tuyến:

Sau đó, trong onCreate (và onNewIntent) của Hoạt động, bạn so khớp ý định đến và khởi chạy ngăn xếp lui:

Kiểu đối số tuỳ chỉnh

Trong Navigation 2, bạn đã xử lý các loại đối số tuỳ chỉnh hoặc của bên thứ ba (chẳng hạn như LocalDateTime) bằng cách sử dụng các phương thức triển khai NavType tuỳ chỉnh và typeMap.

Trong Navigation 3, hãy xác định một DeepLinkSerializer để giải tuần tự hoá các loại tuỳ chỉnh hoặc của bên thứ ba từ các tham số URI. Hãy xem phần Tuỳ chỉnh quy trình chuyển đổi tuần tự bằng DeepLinkSerializer để biết thông tin chi tiết.

Để biết thêm các trường hợp sử dụng nâng cao, bao gồm cả ngăn xếp lui tổng hợp và trình so khớp tuỳ chỉnh, hãy xem hướng dẫn Hỗ trợ đường liên kết sâu.

Bước 8: Xoá các phần phụ thuộc của Navigation 2

Xoá tất cả các lệnh nhập và phần phụ thuộc thư viện của Navigation 2.

Tóm tắt

Xin chúc mừng! Dự án của bạn hiện đã được di chuyển sang Navigation 3. Nếu bạn hoặc tác nhân AI của bạn gặp vấn đề khi sử dụng hướng dẫn này, hãy báo cáo lỗi tại đây.