Lập trình tạo biểu đồ bằng Kotlin DSL

Thành phần Navigation (Điều hướng) sẽ cung cấp một ngôn ngữ đặc tả chuyên biệt dựa trên Kotlin, hay còn gọi là DSL, dựa trên trình tạo kiểu an toàn (type-safe builders) của Kotlin. API này cho phép bạn khai báo biểu đồ của mình trong mã Kotlin, thay vì trong tài nguyên XML. Điều này có thể hữu ích khi bạn muốn tạo tính năng điều hướng tự động cho ứng dụng. Ví dụ: ứng dụng có thể tải xuống và lưu cấu hình điều hướng vào bộ nhớ đệm từ một dịch vụ web bên ngoài, sau đó sử dụng cấu hình đó để tạo động một biểu đồ điều hướng trong hàm onCreate() của hoạt động đó.

Phần phụ thuộc

Để sử dụng Kotlin DSL, hãy thêm phần phụ thuộc sau vào tệp build.gradle của ứng dụng:

Groovy

dependencies {
    def nav_version = "2.4.2"

    api "androidx.navigation:navigation-fragment-ktx:$nav_version"
}

Kotlin

dependencies {
    val nav_version = "2.4.2"

    api("androidx.navigation:navigation-fragment-ktx:$nav_version")
}

Tạo biểu đồ

Hãy bắt đầu bằng một ví dụ cơ bản dựa trên ứng dụng Sunflower (Hoa hướng dương). Với ví dụ này, chúng ta có hai đích đến: homeplant_detail. Đích đến home xuất hiện khi người dùng khởi chạy ứng dụng lần đầu. Đích đến này sẽ hiển thị danh sách các cây trồng trong vườn của người dùng. Khi người dùng chọn một trong các cây trồng này, ứng dụng sẽ điều hướng đến đích đến plant_detail.

Hình 1 cho thấy những đích đến này cùng với các đối số cần có cho đích đến plant_detail và một thao tác to_plant_detail được ứng dụng sử dụng để di chuyển từ home đến plant_detail.

Ứng dụng Sunflower có hai đích đến cùng thao tác kết nối giữa các đích đến này.
Hình 1. Ứng dụng Sunflower có hai đích đến là homeplant_detail, cùng một thao tác kết nối giữa các đích đến này.

Lưu trữ biểu đồ điều hướng Kotlin DSL

Trước khi tạo biểu đồ điều hướng cho ứng dụng, bạn cần một nơi để lưu trữ biểu đồ đó. Ví dụ bên dưới sẽ sử dụng các mảnh (fragment), do vậy biểu đồ sẽ được lưu trữ trong NavHostFragment ở bên trongFragmentContainerView:

<!-- activity_garden.xml -->
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true" />

</FrameLayout>

Lưu ý rằng thuộc tính app:navGraph không được thiết lập trong ví dụ này. Biểu đồ này không được định nghĩa là một tài nguyên (resource) trong thư mục res/navigation, do đó bạn phải thiết lập biểu đồ này thành một phần của quy trình onCreate() trong hoạt động.

Trong XML, một thao tác liên kết mã nhận dạng của một đích đến chứa một hoặc nhiều đối số. Tuy nhiên, khi sử dụng DSL điều hướng (Navigation DSL), một tuyến đường có thể chứa các đối số như một phần của tuyến đường đó. Điều này có nghĩa sẽ không có khái niệm về thao tác khi sử dụng DSL.

Bước tiếp theo sẽ định nghĩa một số hằng số bạn sẽ sử dụng khi định nghĩa biểu đồ.

Tạo hằng số cho biểu đồ

Biểu đồ điều hướng dựa trên XML sẽ được phân tích cú pháp như một bước trong quy trình xây dựng Android. Hệ thống sẽ tạo một hằng số cho từng thuộc tính id được định nghĩa trong biểu đồ. Các ID tĩnh tạo ra tại thời điểm xây dựng (build time) này không có hiệu lực trong quá trình tạo biểu đồ điều hướng trong thời gian chạy. Do đó, DSL điều hướng sẽ sử dụng chuỗi định tuyến (route string) thay vì mã nhận dạng (ID). Mỗi tuyến đường được biểu thị bằng một chuỗi duy nhất và bạn nên định nghĩa các tuyến này dưới dạng hằng số để giảm nguy cơ xảy ra lỗi liên quan đến lỗi đánh máy.

Khi xử lý đối số, các hằng số này sẽ được tích hợp vào chuỗi định tuyến. Việc áp dụng logic xử lý này vào tuyến đường một lần sẽ giúp giảm nguy cơ xảy ra lỗi liên quan đến đánh máy.

object nav_routes {
    const val home = "home"
    const val plant_detail = "plant_detail"
}

object nav_arguments {
    const val plant_id = "plant_id"
    const val plant_name = "plant_name"
}

Sau khi định nghĩa các hằng số của mình, bạn có thể tạo biểu đồ điều hướng.

val navController = findNavController(R.id.nav_host_fragment)
navController.graph = navController.createGraph(
    startDestination = nav_routes.home
) {
    fragment<HomeFragment>(nav_routes.home) {
        label = resources.getString(R.string.home_title)
    }

    fragment<PlantDetailFragment>(${nav_routes.plant_detail}/${nav_arguments.plant_id}) {
        label = resources.getString(R.string.plant_detail_title)
        argument(nav_arguments.plant_id) {
            type = NavType.StringType
        }
    }
}

Trong ví dụ này, trailing lambda sẽ định nghĩa hai đích đến của mảnh bằng hàm tạo DSL fragment(). Hàm này yêu cầu một chuỗi định tuyến cho đích đến lấy từ các hằng số. Hàm này cũng chấp nhận một lambda tuỳ chọn cho phần cấu hình bổ sung, chẳng hạn như nhãn đích, cũng như các hàm tạo được nhúng sẵn cho các đối số và liên kết sâu.

LớpFragment dùng để quản lý giao diện người dùng của từng đích đến được truyền dưới dạng kiểu tham số bên trong dấu nhọn (<>). Cách làm này có tác dụng tương tự như việc thiết lập thuộc tính android:name trên các đích đến của mảnh được định nghĩa bằng XML.

Cuối cùng, bạn có thể điều hướng từ home đến plant_detail bằng cách sử dụng lệnh gọi NavController.Navigation() chuẩn:

private fun navigateToPlant(plantId: String) {
   findNavController().navigate("${nav_routes.plant_detail}/$plantId")
}

Trong PlantDetailFragment, bạn có thể lấy giá trị của đối số như được thể hiện trong ví dụ sau:

val plantId: String? = arguments?.getString(nav_arguments.plant_id)

Bạn có thể xem thông tin chi tiết về cách cung cấp đối số khi điều hướng trong phần cung cấp đối số cho đích đến.

Phần còn lại của hướng dẫn này sẽ mô tả các thành phần của biểu đồ điều hướng phổ biến, đích đến và cách sử dụng các yếu tố này khi tạo biểu đồ.

Đích đến

Kotlin DSL cung cấp cơ chế hỗ trợ được tích hợp sẵn cho ba loại đích đến: Fragment, Activity, và NavGraph. Mỗi đích đến này đều có chức năng tiện ích cùng dòng (inline) riêng để tạo và định cấu hình cho đích đến đó.

Đích đến của mảnh

Hàm DSL fragment() có thể chứa tham số cho lớp của mảnh triển khai (implementing fragment class) và lấy một chuỗi định tuyến duy nhất để gán cho đích đến này, theo sau là một Lambda dùng để cung cấp cấu hình bổ sung như đã mô tả trong phần Điều hướng bằng biểu đồ Kotlin DSL.

fragment<FragmentDestination>(nav_routes.route_name) {
   label = getString(R.string.fragment_title)
   // arguments, deepLinks
}

Đích đến của hoạt động

Hàm DSL activity() sử dụng một chuỗi định tuyến riêng để gán cho đích đến này, nhưng không chứa tham số cho bất kỳ lớp hoạt động triển khai nào. Thay vào đó, bạn sẽ thiết lập một activityClass tuỳ chọn trong một trailing lambda. Tính linh hoạt này cho phép bạn định nghĩa một đích đến hoạt động (activity destination) cho một hoạt động sẽ được khởi chạy bằng ý định ngầm ẩn (implicit intent) (sẽ không hợp lý khi dùng một lớp hoạt động tường minh ở đây). Tương tự như các đích đến của mảnh, bạn cũng có thể định cấu hình nhãn, đối số và liên kết sâu.

activity(nav_routes.route_name) {
   label = getString(R.string.activity_title)
   // arguments, deepLinks...

   activityClass = ActivityDestination::class
}

Bạn có thể sử dụng hàm DSL navigation() để tạo một biểu đồ điều hướng lồng nhau. Hàm này có ba đối số: tuyến đường được dùng để gán cho biểu đồ, tuyến đường của đích bắt đầu (starting destination) của biểu đồ và một lambda để cấu hình thêm cho biểu đồ. Các phần tử hợp lệ bao gồm các đích đến khác, các đối số, liên kết sâu và nhãn mô tả về đích đến. Nhãn này có thể hữu ích trong quá trình liên kết biểu đồ điều hướng với các thành phần giao diện người dùng thông qua NavigationUI

navigation("route_to_this_graph", nav_routes.home) {
   // label, other destinations, deep links
}

Hỗ trợ đích đến tuỳ chỉnh

Nếu sử dụng kiểu đích đến mới không trực tiếp hỗ trợ Kotlin DSL, bạn có thể thêm các đích đến này vào Kotlin DSL thông qua addDestination():

// The NavigatorProvider is retrieved from the NavController
val customDestination = navigatorProvider[CustomNavigator::class].createDestination().apply {
    route = Graph.CustomDestination.route
}
addDestination(customDestination)

Ngoài ra, bạn cũng có thể sử dụng toán tử cộng một ngôi (unary plus operator) để thêm trực tiếp điểm đến mới được tạo vào biểu đồ:

// The NavigatorProvider is retrieved from the NavController
+navigatorProvider[CustomNavigator::class].createDestination().apply {
    route = Graph.CustomDestination.route
}

Cung cấp các đối số cho đích đến

Bất kỳ đích đến nào cũng có thể định nghĩa các đối số là tuỳ chọn hoặc bắt buộc. Bạn có thể định nghĩa các thao tác bằng cách sử dụng hàm argument() trên NavDestinationBuilder, là lớp cơ sở cho mọi kiểu tạo đích đến (destination builder types). Hàm này lấy tên của đối số dưới dạng một chuỗi và một lambda được dùng để tạo và định cấu hình NavArgument.

Bên trong lambda này, bạn có thể chỉ định kiểu dữ liệu cho đối số, giá trị mặc định nếu có và giá trị này có thể rỗng hay không.

fragment<PlantDetailFragment>(${nav_routes.plant_detail}/${nav_arguments.plant_id}) {
    label = getString(R.string.plant_details_title)
    argument(nav_arguments.plant_id) {
        type = NavType.StringType
        defaultValue = getString(R.string.default_plant_id)
        nullable = true  // default false
    }
}

Nếu đối số được cung cấp là một defaultValue thì bạn có thể suy luận được kiểu tương ứng cho đối số này. Nếu đối số bao gồm cả defaultValuetype thì các kiểu của những đối số này phải khớp nhau. Hãy xem tài liệu tham khảo NavType để biết danh sách đầy đủ các loại đối số có sẵn.

Cung cấp các kiểu tuỳ chỉnh

Một số kiểu nhất định nào đó, chẳng hạn như ParcelableTypeSerializableType, không hỗ trợ phân tích cú pháp các giá trị trong các chuỗi được sử dụng theo tuyến đường hay liên kết sâu. Lý do là các giá trị này không dựa vào cơ chế phản chiếu trong thời gian chạy. Bằng cách cung cấp một lớp NavType tuỳ chỉnh, bạn có thể kiểm soát chính xác cách phân tích cú pháp một kiểu nào đó trong một tuyến đường hoặc liên kết sâu. Điều này cho phép bạn sử dụng quy trình chuyển đổi tuần tự Kotlin hoặc các thư viện khác để cung cấp tính năng mã hoá và giải mã không theo cơ chế phản chiếu (reflectionless encoding and decoding) cho kiểu tuỳ chỉnh.

Ví dụ: một lớp dữ liệu đại diện cho các tham số tìm kiếm đang được truyền đến màn hình tìm kiếm có thể được triển khai cả Serializable (để cung cấp tính năng hỗ trợ mã hoá/giải mã) và Parcelize (hỗ trợ lưu trữ và phục hồi một Bundle):

@Serializable
@Parcelize
data class SearchParameters(
  val searchQuery: String,
  val filters: List<String>
)

NavType tuỳ chỉnh có thể được viết thành:

class SearchParametersType : NavType<SearchParameters>(
  isNullableAllowed = false
) {
  override fun put(bundle: Bundle, key: String, value: SearchParameters) {
    bundle.putParcelable(key, value)
  }
  override fun get(bundle: Bundle, key: String): SearchParameters {
    return bundle.getParcelable(key) as SearchParameters
  }

  override fun parseValue(value: String): SearchParameters {
    return Json.decodeFromString<SearchParameters>(value)
  }

  // Only required when using Navigation 2.4.0-alpha07 and lower
  override val name = "SearchParameters"
}

Sau đó, bạn có thể dùng kiểu tuỳ chỉnh này trong Kotlin DSL như bất kỳ kiểu nào khác:

fragment<SearchFragment>(nav_routes.plant_search) {
    label = getString(R.string.plant_search_title)
    argument(nav_arguments.search_parameters) {
        type = SearchParametersType
        defaultValue = SearchParameters("cactus", emptyList())
    }
}

Ví dụ này sử dụng tính năng chuyển đổi tuần tự Kotlin để phân tích cú pháp giá trị của chuỗi. Điều này có nghĩa là bạn phải sử dụng tính năng chuyển đổi tuần tự Kotlin khi điều hướng đích đến này nhằm đảm bảo các định dạng sẽ khớp với nhau:

val params = SearchParameters("rose", listOf("available"))
val searchArgument = Uri.encode(Json.encodeToString(params))
navController.navigate("${nav_routes.plant_search}/$searchArgument")

Tham số này có thể được lấy từ các đối số trong đích đến:

val params: SearchParameters? = arguments?.getParcelable(nav_arguments.search_parameters)

Liên kết sâu

Bạn có thể thêm liên kết sâu vào bất kỳ đích đến nào, giống như cách thêm liên kết sâu vào biểu đồ điều hướng bằng XML. Tất cả quy trình tương tự được định nghĩa trong Tạo liên kết sâu đến một đích đến sẽ được áp dụng cho quy trình tạo liên kết sâu tường minh (explicit deep link) thông qua Kotlin DSL.

Tuy nhiên, khi tạo một liên kết sâu ngầm ẩn, bạn không có một tài nguyên điều hướng XML nào có thể được phân tích cho các phần tử <deepLink>. Do đó, bạn không thể dựa vào việc đặt phần tử <nav-graph> trong tệp AndroidManifest.xml mà thay vào đó, hãy thêm các bộ lọc ý định vào hoạt động theo cách thủ công. Bộ lọc ý định bạn sẽ cung cấp phải khớp với mẫu URL cơ sở, hành động và kiểu mimetype của liên kết sâu của ứng dụng.

Bạn có thể cung cấp một deeplink cụ thể hơn cho từng đích đến được liên kết sâu riêng lẻ bằng cách sử dụng hàm DSL deepLink(). Hàm này chấp nhận một NavDeepLink có chứa một String đại diện cho mẫu URI, một String đại diện cho các thao tác theo ý định và một String đại diện cho kiểu mimeType.

Ví dụ:

deepLink {
    uriPattern = "http://www.example.com/plants/"
    action = "android.intent.action.MY_ACTION"
    mimeType = "image/*"
}

Không có giới hạn về số lượng liên kết sâu có thể thêm. Mỗi lần gọi deepLink(), một liên kết sâu mới sẽ được bổ sung vào một danh sách được duy trì cho đích đến đó.

Các kịch bản phức tạp khác về liên kết sâu cũng định được đường dẫn và các tham số dựa trên truy vấn (query-based) sẽ được trình bày như bên dưới:

val baseUri = "http://www.example.com/plants"

fragment<PlantDetailFragment>(nav_routes.plant_detail) {
   label = getString(R.string.plant_details_title)
   deepLink(navDeepLink {
    uriPattern = "${baseUri}/{id}"
   })
   deepLink(navDeepLink {
    uriPattern = "${baseUri}/{id}?name={plant_name}"
   })
}

Bạn có thể sử dụng loại nội suy chuỗi để đơn giản hoá định nghĩa này.

Các điểm hạn chế

Trình bổ trợ Ang Args không tương thích với Kotlin DSL vì trình bổ trợ này tìm các tệp tài nguyên XML để tạo các lớp DirectionsArguments.