# Android Jetpack **Repository Path**: beyond-prototype/jetpack ## Basic Information - **Project Name**: Android Jetpack - **Description**: Android Jetpack入门 - **Primary Language**: Android - **License**: GPL-2.0 - **Default Branch**: master - **Homepage**: https://gitee.com/beyond-prototype/jetpack - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2021-10-29 - **Last Updated**: 2022-03-30 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README ## Android Jetpack入门 ### 1. Jetpack简介 Android官网的[Jetpack](https://developer.android.google.cn/jetpack)简介: **Jetpack是一个由多个库组成的套件,可帮助开发者遵循最佳做法、减少样板代码并编写可在各种 Android 版本和设备中一致运行的代码,让开发者可将精力集中于真正重要的编码工作。** 1)遵循最佳做法: Jetpack组件采用最新的设计方法构建,具有向后兼容性,可以减少崩溃和内存泄露。 2)减少样板代码: Jetpack可以管理各种繁琐的活动(如后台任务、导航和生命周期管理),让开发者专注于业务逻辑。 3)减少不一致性: Jetpack组件可在各种 Android 版本和设备中以一致的方式运作,降低App开发的复杂性。 有关Jetpack的详细介绍,可以参考以下文章: 1)[Jetpack 是什么?](https://zhuanlan.zhihu.com/p/334350927) 2)[AndroidX 的前世今生](https://zhuanlan.zhihu.com/p/336984453) ### 2. Demo App 这里以微信作为原型,使用Jetpack的组件来构建一个Demo App。 ### 3. Demo App 技术架构 #### 3.1 View Layer 界面层 界面层使用Jetpack Compose来构建界面元素,使用Jetpack Navigation来实现导航功能。 界面层的每个屏幕对应一个Screen级别的Composable函数,它接收来自业务层的数据并将数据传播给其他细粒度的Composable函数,通过组合其他更小粒度的Composable函数来构建一个完整的屏幕界面。 Composable函数接收可观察的数据(State),并将数据的变化更新到相应的界面组件。 Composable函数(例如Button的clickable属性)通过监听用户交互事件(Event)触发ViewModel的Command来执行业务逻辑。 Constraints用于制定界面元素的尺寸例如宽高,仅供Composable函数内部使用。 #### 3.2 Business Layer 业务层 使用ViewModel来管理业务逻辑和控制数据访问,ViewModel的主要职责是: 1)给界面层的Composable函数提供数据 调用Repository获取被界面观察的数据,并通过State Holder将数据提供给View界面层 例如 ```kotlin val uiState = TopicDetailState() init { launch { uiState.topic = topicRepository.getOne(topicId) uiState.messages = messageRepository.get(topicId) } } ``` 2)响应界面层的用户事件并执行封装在Command组件的业务逻辑,最终通过更新State或者更新Model(通过Flow将变化自动转换成State)来反馈用户的操作结果。 例如在当发送用户消息时,我们把消息存到本地数据库,并清空界面的输入框的消息。 ```kotlin val uiCommands = object : TopicDetailCommands { override fun sendMessage() { //logic omitted } } ``` #### 3.3 Data Access Layer 数据访问层 数据访问层主要有三大组件: 1)Repository: 提供统一的数据访问接口给业务层,其内部则使用Dao和WebService来获取或修改数据。 2)Dao(Data Access Object):访问本地数据源例如Room数据库的接口,并使用Flow将数据传送给界面层,由界面层转换成可观察的State。 ```kotlin @Dao interface MessageDao : BasicDao { @Query("select * from message where topic_id = :topicId order by id asc") fun get(topicId:Long): Flow> } ``` 3)WebService:访问后台数据的接口。如果本地数据源没有数据,或者数据跟后台不同步,Repository则需要通过WebService从后台获取最新数据并同步到Room数据库。 ```kotlin interface MessageService { /** * get all messages for the specific topic */ @GET("message/{topicId}/list") fun getMessages(@Path("topicId") topicId: Long): Call>> } ``` ### 4. Jetpack Compose [Jetpack Compose](https://developer.android.google.cn/jetpack/compose) 是用于构建原生 Android 界面的新工具包。 [为什么采用 Compose](https://developer.android.google.cn/jetpack/compose/why-adopt#intuitive) 1) 更少的代码: 使用更少的代码实现更多的功能,并且可以避免各种 bug,从而使代码简洁且易于维护。 2) 直观: Compose提供声明性的API,我们只需描述界面,Compose 会负责处理剩余的工作。应用状态变化时,界面会自动更新。 (除了Compose,Flutter和SwiftUi也支持声明式的界面开发方法,这三者有很多相似之处) 3) 加快应用开发: 兼容现有的所有代码,支持View和Compose的相互调用。借助实时预览和全面的 Android Studio 支持,实现快速迭代。 4) 功能强大: 直接访问 Android 平台 API,内置对 Material Design、深色主题、动画等的支持。 有关Jetpack Compose的介绍,可以参考以下文章: 1) [Jetpack Compose 教程](https://developer.android.google.cn/jetpack/compose/tutorial) 2) [Compose 编程思想](https://developer.android.google.cn/jetpack/compose/mental-model) 3) [深入详解 Jetpack Compose | 优化 UI 构建](https://zhuanlan.zhihu.com/p/267250784) 4) [Jetpack Compose Android UI 开发的最终形态?](https://zhuanlan.zhihu.com/p/356131023) #### 4.1 Composable 可组合函数(组合模式,组合优于继承) Composable函数应该尽可能设计为Stateless无状态的,以便于重用和测试。 状态提升(State hoisting)是一种实现无状态Composable的方案,Composable不再管理它使用的State,而是让Composable的使用者(例如ViewModel)来管理State。 每个Screen级别的Composable函数接收以下三种参数: 1) State Holder: Composable函数观察的数据容器,Composable函数通过collectAsState()把容器内的数据转换成可观察的State,并State传递给它调用的其他Composable函数。 例如聊天模块使用的TopicDetailState,它由顶部的"主题"(topic)和聊天窗口的"消息"(messages)等组成,这些数据由ViewModel从数据层获得。 ```kotlin class TopicDetailState { var topic: Flow = emptyFlow() var messages:Flow> = emptyFlow() } ``` TopicDetailScreen()函数将topic和messages转换层可观察的State ```kotlin val topic by viewModel.uiState.topic.collectAsState(initial = Topic()) val messages by viewModel.uiState.messages.collectAsState(initial = emptyList()) ``` 2) Command:处理界面事件(Event)的业务逻辑接口,具体业务逻辑则由业务层实现。 例如当用户点击"Send"按钮发送消息时,onClick事件将调用发送消息的接口sendMessage() ```kotlin Button(onClick = { commands.sendMessage() }){ Text("Send",modifier = Modifier.padding(0.dp), style = DemoTheme.typography.button) } ``` Command接口 ```kotlin interface TopicDetailCommands { fun sendMessage() } ``` 3) Constraints:用于管理界面元素的尺寸,只在界面内部使用。 当需要修改界面元素的尺寸时,只需修改Constraints类,而不用逐个界面逐个元素去修改。 如果屏幕的尺寸发生变化,例如横竖屏切换的时候,Constraints会自动从新计算界面元素尺寸。 ```kotlin companion object{ val instance = LayoutConstraints() fun init(configuration: Configuration): LayoutConstraints{ synchronized(instance) { if (configuration.screenWidthDp != instance.screenWithDp) { instance.init(configuration) } } return instance } } ``` #### 4.2 Compose Navigation 导航 Demo App的导航概览如下图 Demo App由三大模块组成,分别是Chat,Contact和Discover。 为了避免模块之间的耦合,每个模块只负责管理自身内部的导航功能,每个模块创建自身的导航图(NavGraph),然后由DemoApp的MainActivity来负责集成各个模块的导航图。 [MainActivity](app/src/main/java/com/example/demo/MainActivity.kt)的示例代码如下 ```kotlin val navController = rememberNavController() val startDestination = remember { featureSDKs[0].featureKey() } NavHost(navController = navController, startDestination = startDestination) { featureSDKs.forEach { featureSdk -> featureSdk.buildNavigationGraph(this, navController) } sharedNestedNavigationGraph(navController, featureSDKs) } ``` 这里使用了Compose提供的NavHost和NavHostController来管理整个App的导航图,并把第一个Feature SDK(例如Chat)作为导航图的路由起点。 为了避免Demo App和各个模块的耦合,每个模块都是通过Feature SDK的方式集成到Demo App,每个Feature SDK都实现buildNavigationGraph(...)接口来提供该模块的导航图。 [ChatFeatureSDK](chat-feature-sdk/src/main/java/com/example/chat/ChatFeatureSDK.kt)的示例代码如下: ```kotlin override fun buildNavigationGraph(navGraphBuilder: NavGraphBuilder, navHostController: NavHostController) { navGraphBuilder.chatNestedNavigationGraph(navHostController, featureKey()) } ``` 每个Feature SDK用它全局唯一的Feature Key通过navigation(...)函数定义该模块导航图的路由入口,并指定该模块的起始页面(例如Screen.TopicList.route),每个可导航的页面则使用composable(...)函数来定义。 [NavigationGraph](chat-feature-sdk/src/main/java/com/example/chat/navigation/NavigationGraph.kt)的示例代码如下,这里通过给Compose的NavGraphBuilder提供了扩展函数chatNestedNavigationGraph(...)来定义模块的导航图。 ```kotlin fun NavGraphBuilder.chatNestedNavigationGraph(navHostController: NavHostController, featureKey: String) { NavigationController.instance.navController = navHostController navigation(startDestination = Screen.TopicList.route, route = featureKey) { composable(Screen.TopicList.route) { val viewModel: TopicListViewModel = hiltViewModel() val uiConstraints = LayoutConstraints.init(LocalConfiguration.current) TopicListScreen(viewModel.uiState, uiConstraints) } composable(Screen.TopicDetail.route, arguments = listOf(navArgument("id"){ type = NavType.LongType})){ val viewModel: TopicDetailViewModel = hiltViewModel() val uiConstraints = LayoutConstraints.init(LocalConfiguration.current) TopicDetailScreen(viewModel.uiState, uiConstraints, viewModel.uiCommands) } composable(Screen.TopicSetting.route, arguments = listOf(navArgument("id"){ type = NavType.LongType})){ val uiConstraints = LayoutConstraints.init(LocalConfiguration.current) TopicSettingScreen(uiConstraints) } } } ``` 为了便于管理路由,这里定义了Screen类管理每个页面的路径。路径可以包含参数,由composable(...)函数的arguments参数指定。 [Screen](chat-feature-sdk/src/main/java/com/example/chat/navigation/Screen.kt)的示例代码如下 ```kotlin enum class Screen(val route: String) { TopicList("chat/topic/list"), TopicDetail("chat/topic/{id}/detail"), TopicSetting("chat/topic/{id}/setting") } ``` 当用户在主题列表TopicListView(左图)里点击某个主题TopicEntryView后就会被导航到对应的主题聊天详情页面(右图) [TopicListScreen](chat-feature-sdk/src/main/java/com/example/chat/view/TopicListScreen.kt)的示例代码如下 ```kotlin @Composable fun TopicListView(topics: List, uiConstraints: LayoutConstraints, modifier:Modifier = Modifier.fillMaxSize()){ LazyColumn(modifier = modifier.background(DemoTheme.colors.secondary), contentPadding = PaddingValues(horizontal = 0.dp,vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)){ items(items = topics, key = {topic -> topic.id!!}){ topic -> TopicEntryView(topic, uiConstraints){ NavigationController.instance.navigate(Screen.TopicDetail, topic.id.toString()) } } } } ``` 每个模块内部的导航这里使用了[NavigationController](chat-feature-sdk/src/main/java/com/example/chat/navigation/NavigationController.kt), 其内部使用了Compose提供的NavHostController来做模块内部的页面导航,navController?.navigate(...),代码如下: ```kotlin fun navigate(screen: Screen, vararg params: String, builder: NavOptionsBuilder.() -> Unit = {launchSingleTop = true}) { when(screen){ Screen.TopicDetail -> navController?.navigate(screen.route.replace("{id}",params[0])){ builder() } Screen.TopicSetting -> navController?.navigate(screen.route.replace("{id}",params[0])) else -> navController?.navigate(screen.route){ builder() } } } override fun navigate(route: String, vararg params: String, builder: NavOptionsBuilder.() -> Unit) { Screen.values().lastOrNull { it.route == route }?.let { navigate(it, *params){ builder() } } } ``` 为了避免模块之间的耦合,模块于模块之间的导航借助NavigationMediator。 例如在Chat模块到聊天详情页面(左图)点击头像可以导航到Contact模块到Contact详情页面(右图),或者在Contact详情页面(右图)点击Message按钮进入到聊天详情页面(左图)。 使用NavigationMediator提供的navigate方法导航到另一个模块,尽可能地减少来模块之间的耦合,特别是模块间的循环依赖问题。 具体做法是当Feature SDK注册到NavigationMediator的时候,同时将它的所有导航路径和它的NavigationController都一并注册到NavigationMediator。 模块间的导航通过NavigationMediator查询目标路径是否存在,如果存在则调用对应的NavigationController导航到目标页面。 [NavigationMediator](app/src/main/java/com/example/demo/NavigationMediator.kt)的示例代码如下: ```kotlin private val _routes = mutableMapOf() override fun register(featureSDK: AbstractFeatureSDK) { Log.i(TAG, "***register feature: key=${featureSDK.featureKey()}, class=${featureSDK.javaClass.canonicalName}") _features.add(featureSDK) val navigationController = featureSDK.getNavigationController() navigationController.getNavigationRoutes().forEach { _routes[it] = navigationController Log.i(TAG,"***register route: $it") } } override fun navigate(route: String, vararg params: String, builder: NavOptionsBuilder.() -> Unit) { Log.i(TAG, "route=$route, hasRoute=${hasRoute(route)}") if(hasRoute(route)){ _routes[route]?.navigate(route, *params){ builder() } } } ``` #### 4.3 Compose Material Design 使用[Material Design](https://developer.android.google.cn/jetpack/compose/tutorial#lesson-3:-material-design) 管理界面风格,支持深色主题(夜间模式)。 **自定义Theme** 创建[DemoTheme](core-feature-sdk/src/main/java/com/example/featuresdk/theme/Theme.kt),它使用了Compose的MaterialTheme,示例代码如下 ```kotlin @Composable fun DemoTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit ) { MaterialTheme( colors = if (darkTheme) { DarkColorPalette } else { LightColorPalette }, content = content ) } ``` 这里使用Compose提供的isSystemInDarkTheme()函数来判断系统是否处于深色模式,并使用DemoTheme自定义的相应样式。 Theme主要由三大部分组成: **1)Color颜色** 这些样式(DarkColorPalette或者LightColorPalette)都定义在 [Color](core-feature-sdk/src/main/java/com/example/featuresdk/theme/Color.kt),示例代码如下: ```kotlin val DarkColorPalette = darkColors( primary = Color.Black, onPrimary = Color.White, secondary = Color(0xFF1F1F1C), onSecondary = Color.White ) val LightColorPalette = lightColors( primary = Color(0xE2ECECF0), onPrimary = Color.Black, secondary = Color.White, onSecondary = Color.Black ) ``` Compose提供来默认的darkColors(...)和lightColors(...)来定义颜色,DemoApp通过它们来定制颜色样式。 **2)Typography排版** 排版样式在[Typography](core-feature-sdk/src/main/java/com/example/featuresdk/theme/Typography.kt)中根据需要定义自己的字体,包括名称,大小、颜色,风格等。 Compose的Typography提供了以下默认字体名称来设置字体样式,当然我们也自定义自己的字体名称。 ```html h1 - h1 is the largest headline, reserved for short, important text or numerals. h2 - h2 is the second largest headline, reserved for short, important text or numerals. h3 - h3 is the third largest headline, reserved for short, important text or numerals. h4 - h4 is the fourth largest headline, reserved for short, important text or numerals. h5 - h5 is the fifth largest headline, reserved for short, important text or numerals. h6 - h6 is the sixth largest headline, reserved for short, important text or numerals. subtitle1 - subtitle1 is the largest subtitle, and is typically reserved for medium-emphasis text that is shorter in length. subtitle2 - subtitle2 is the smallest subtitle, and is typically reserved for medium-emphasis text that is shorter in length. body1 - body1 is the largest body, and is typically used for long-form writing as it works well for small text sizes. body2 - body2 is the smallest body, and is typically used for long-form writing as it works well for small text sizes. button - button text is a call to action used in different types of buttons (such as text, outlined and contained buttons) and in tabs, dialogs, and cards. caption - caption is one of the smallest font sizes. It is used sparingly to annotate imagery or to introduce a headline. overline - overline is one of the smallest font sizes. It is used sparingly to annotate imagery or to introduce a headline. ``` 例如这里创建一个名为header的字体专门用于Demo App的顶部导航栏的标题。 参考如下[Typography](core-feature-sdk/src/main/java/com/example/featuresdk/theme/Typography.kt)示例代码。 ```kotlin @Immutable data class DemoTypography constructor( val header : TextStyle = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Bold, fontSize = 20.sp, textAlign = TextAlign.Center ) ) ``` **3)Shape形状** Compose默认提供了如下三种圆角形状:small,medium和large ```kotlin package androidx.compose.material @Immutable class Shapes( /** * Shape used by small components like [Button] or [Snackbar]. Components like * [FloatingActionButton], [ExtendedFloatingActionButton] use this shape, but override * the corner size to be 50%. [TextField] uses this shape with overriding the bottom corners * to zero. */ val small: CornerBasedShape = RoundedCornerShape(4.dp), /** * Shape used by medium components like [Card] or [AlertDialog]. */ val medium: CornerBasedShape = RoundedCornerShape(4.dp), /** * Shape used by large components like [ModalDrawer] or [ModalBottomSheetLayout]. */ val large: CornerBasedShape = RoundedCornerShape(0.dp) ) ``` 我们可以创建自己的[Shape](core-feature-sdk/src/main/java/com/example/featuresdk/theme/Shape.kt)系统来扩展默认的Shapes ```kotlin @Immutable data class DemoShapes( val small: Shape = DefaultShapes.small, val medium: Shape = DefaultShapes.medium, val large: Shape = DefaultShapes.large, val xlarge: Shape = RoundedCornerShape(0.dp) ) ``` **DemoTheme的使用** 1)在[MainActivity](app/src/main/java/com/example/demo/MainActivity.kt)的setContent()中使用DemoTheme()函数将样式设置给所有Composable页面 ```kotlin setContent { DemoTheme { if (featureSDKs.isNotEmpty()) { val navController = rememberNavController() val startDestination = remember { featureSDKs[0].featureKey() } NavHost(navController = navController, startDestination = startDestination) { featureSDKs.forEach { featureSdk -> featureSdk.buildNavigationGraph(this, navController) } sharedNestedNavigationGraph(navController, featureSDKs) } SearchScreen() } } } ``` 2)在[DemoTheme](core-feature-sdk/src/main/java/com/example/featuresdk/theme/Theme.kt)中创建DemoTheme(名字随意,建议跟跟Composable函数相同)对象,这样我们在页面中可以通过它来访问需要的具体样式条目, ```kotlin object DemoTheme { val colors: Colors @Composable get() = MaterialTheme.colors val typography: DemoTypography @Composable get() = LocalDemoTypography.current val shapes: DemoShapes @Composable get() = LocalDemoShapes.current } ``` 例如AppHeaderView通过DemoTheme.colors.primary可以获取primary颜色来设置顶部导航栏的背景色,示例代码如下background(DemoTheme.colors.primary) ```kotlin @Composable inline fun AppHeaderView(heightDp: Int, content: @Composable () -> Unit){ Row(modifier = Modifier .background(DemoTheme.colors.primary) .height(heightDp.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { content() } } ``` 组件的内容字体颜色通过相应的contentColorFor(...)函数来设置,通过style属性设置文字样式,示例代码如下: ```kotlin @Composable fun AppHeaderTitle(title:String = "", headerWidthDp:Int){ Text(text = title, modifier = Modifier.width(headerWidthDp.dp), style = DemoTheme.typography.header, color = contentColorFor(DemoTheme.colors.primary)) } ``` ### 5. Room: 本地数据库的持久化库 [Room](https://developer.android.google.cn/training/data-storage/room) 为SQLite提供了一个抽象层,以便在充分利用SQLite的强大功能的同时,能够流畅地访问数据库。 Room提供两种类型的数据库,内存型和文件型。 参考[AppRoomDatabaseFactory](app/src/main/java/com/example/demo/database/AppRoomDatabaseFactory.kt)示例代码 **内存型数据库**将数据保留在内存,App退出时数据将丢失。一般用于开发阶段,避免因频繁更改Entity而导致数据库迁移的问题。 ```kotlin Room.inMemoryDatabaseBuilder(context.applicationContext, AppRoomDatabase::class.java).build() ``` **文件型数据库**将数据保留在手机设备,App退出时数据不丢失。 ```kotlin Room.databaseBuilder(context.applicationContext, AppRoomDatabase::class.java,"demo_database.db").build() ``` 这里[AppRoomDatabase](app/src/main/java/com/example/demo/database/AppRoomDatabase.kt)指定了数据库的实体类(Entity class,对应于数据库中的表table)和DAO(Data Access Object), 实体类和DAO都是由每个模块提供,统一在AppRoomDatabase里注册,示例代码如下。 ```kotlin @Database(entities = [com.example.chat.model.Message::class ,com.example.chat.model.Topic::class ], version = 1, exportSchema = false) abstract class AppRoomDatabase: RoomDatabase() { abstract fun messageDao(): com.example.chat.repository.MessageDao abstract fun topicDao(): com.example.chat.repository.TopicDao //codes omitted } ``` 每个实体类使用@Entity标签来声明,例如Chat模块的[Message](chat-feature-sdk/src/main/java/com/example/chat/model/Message.kt)实体类 ```kotlin @Entity(tableName = "message") data class Message ( @PrimaryKey (autoGenerate = true) val id: Long? = null, @ColumnInfo(name = "topic_id") val topicId: Long, val sender: Long, val text: String? = null, val image: Int? = null, val status: MessageStatus = MessageStatus.New, @ColumnInfo(name="create_timestamp") val timestamp: Timestamp = Timestamp(System.currentTimeMillis()) ) ``` 每个DAO类使用@Dao标签来声明,例如Chat模块的[MessageDao](chat-feature-sdk/src/main/java/com/example/chat/repository/MessageDao.kt) ```kotlin @Dao interface MessageDao : BasicDao { /** * get all messages for a topic */ @Query("select * from message where topic_id = :topicId order by id asc") fun get(topicId: Long): Flow> } ``` ### 6. Hilt: 建立在Dagger之上的依赖注入库 (没有"刀柄"的"匕首"不好用^_^) Hilt 是 Android 的依赖项注入库,可减少在项目中执行手动依赖项注入的样板代码。 Hilt 通过为项目中的每个 Android 类提供容器并自动管理其生命周期,提供了一种在应用中使用 DI(依赖项注入)的标准方法。 Hilt 在依赖项注入库 Dagger 的基础上构建而成,提供了一种将 Dagger 纳入 Android 应用的标准方法。 1)简化 Android 应用的 Dagger 相关基础架构。 2)创建一组标准的组件和作用域,以简化设置、提高可读性以及在应用之间共享代码。 3)提供一种简单的方法来为各种构建类型(如测试、调试或发布)配置不同的绑定。 关于Hilt的使用,请参考官方介绍[使用 Hilt 实现依赖项注入](https://developer.android.google.cn/training/dependency-injection/hilt-android) 在Compose Navigation使用Hilt时,需要使用[hilt-navigation-compose](https://developer.android.google.cn/jetpack/androidx/releases/hilt) 的hiltViewModel()函数来获取ViewModel实例。 如果使用[lifecycle-viewmodel-compose](https://developer.android.google.cn/jetpack/androidx/releases/lifecycle) 的viewModel()函数,则无法给ViewModel实例注入依赖。 在项目中声明hilt-navigation-compose的依赖 ```kotlin implementation "androidx.hilt:hilt-navigation-compose:$hilt_nav_compose_version" ``` 在ViewModel中声明依赖项,例如ExampleViewModel依赖于dependency1和dependency2 ```kotlin @HiltViewModel class ExampleViewModel @Inject constructor(private val dependency1: Dependency1, private val dependency2: Dependency2): ViewModel() { //code omitted } ``` 在compose navigation里使用hiltViewModel()来获取ExampleViewModel的实例。 ```kotlin val navController = rememberNavController() NavHost(navController, startDestination = "ExampleRoute") { composable("ExampleRoute") { val viewModel = hiltViewModel() } } ``` ### 7. Accessibility 无障碍服务 参考[Accessibility in Compose](https://developer.android.google.cn/jetpack/compose/accessibility) ### 8. Analytics tracking (分析跟踪用户行为) 分析跟踪用户行为,例如用户浏览了哪些页面(数据),点击了哪些按钮(执行了哪些操作)等等,有助于优化App的用户体验,也有利于市场营销(精准推送用户最感兴趣的产品)。 比较出名的解决方案有[Tealium](https://tealium.com/docs-overview/) 和 [Google Analytics](https://developers.google.cn/analytics/devguides/platform) 它们都提供了Web/Android/iOS这些平台对应的集成方案。每个项目使用的解决方案不尽相同,即使同一项目在不同的时期也可能使用不同的解决方案。 下面是来自Google Analytics的一个逻辑架构,概要说明了“数据收集“,”处理分析”和“汇总报告”的整个流程 为了避免不同的解决方案对项目造成侵入性的影响,一般需要在项目中定义一个集成层来屏蔽不同解决方案的差异, 这里我们定义了 一个`IAnalyticsDelegate`接口(简单起见,接口只定义一个参数) ```kotlin interface IAnalyticsDelegate { /** * Track page event e.g. page is loaded when user enters it or page is destroyed when user leaves it */ fun trackPage(pageName: String) /** * Track action event e.g. user clicks a button to submit an order */ fun trackAction(actionName: String) } ``` IAnalyticsDelegate的实现类AnalyticsDelegate则负责将App的数据传给相应解决方案提供的SDK。 对于使用了Compose的页面,需要利用Compose的DisposableEffect[附带效应Side Effects](https://developer.android.google.cn/jetpack/compose/side-effects) 来专门处理页面的生命周期事件。为此,这里创建[ScreenAnalytics](core-feature-sdk/src/main/java/com/example/featuresdk/analytics/ScreenAnalytics.kt) 组合函数来处理Compose页面的加载事件,当用户访问某个Compose页面时,通过监听它的LifecycleOwner的生命周期事件如Lifecycle.Event.ON_START来发送页面信息给后台。 例如这里使用ScreenAnalytics组合函数来收集用户打开聊天详情页面的事件,示例代码如下 ```kotlin ScreenAnalytics(screenName = "TopicDetailScreen") { TopicDetailScreen(viewModel.uiState, uiConstraints, viewModel.uiCommands) } ``` [ScreenAnalytics](core-feature-sdk/src/main/java/com/example/featuresdk/analytics/ScreenAnalytics.kt)的示例代码如下 ```kotlin @Composable fun ScreenAnalytics(lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, onStart: (name: String) -> Unit = { name -> AnalyticsDelegate.instance.trackPage(name) }, // Send the 'started' analytics event screenName:String = "Unknown", screenContent: @Composable ()-> Unit){ // Safely update the current lambdas when a new one is provided val currentOnStart by rememberUpdatedState(onStart) // If `lifecycleOwner` changes, dispose and reset the effect DisposableEffect(lifecycleOwner) { // Create an observer that triggers our remembered callbacks for sending analytics events val observer = LifecycleEventObserver { _, event -> when(event){ Lifecycle.Event.ON_START -> currentOnStart(screenName) else -> { //Log.i("ScreenLoadAnalytics", "event=${event.name} screenName=$screenName lifecycleOwner=$source") } } } // Add the observer to the lifecycle lifecycleOwner.lifecycle.addObserver(observer) // When the effect leaves the Composition, remove the observer onDispose { //Log.e("ScreenLoadAnalytics","onDispose: lifecycleOwner=$lifecycleOwner screenName=$screenName") lifecycleOwner.lifecycle.removeObserver(observer) } } screenContent() } ```