Now in Android 通知模块剖析:基于 `:core:notifications` 的新闻推送架构设计

Now in Android 通知模块剖析:基于 `:core:notifications` 的新闻推送架构设计 Now in Android 通知模块剖析基于:core:notifications的新闻推送架构设计【免费下载链接】nowinandroidA fully functional Android app built entirely with Kotlin and Jetpack Compose项目地址: https://gitcode.com/GitHub_Trending/no/nowinandroidcore/notifications/README.md是 Now in Android 项目中:core:notifications模块的索引文档其核心内容是模块定位与依赖关系图。本文以该文档为骨架结合模块内Notifier、SystemTrayNotifier、NoOpNotifier等源码实现完整还原这个新闻通知核心模块的设计思路接口抽象、系统托盘实现、Hilt 构建变体注入、权限与通知渠道管理以及从数据层同步到 For You 深链消费的完整调用链帮助你掌握在 Compose 应用中构建可测试、可替换通知能力的实战方案。模块定位与依赖关系core/notifications/README.md明确给出该模块的依赖图Mermaid从依赖图可以读出三个关键事实:core:notifications是一个 Android library 模块而非 JVM 纯逻辑库——因为它依赖android.app.Notification、NotificationManager等 Android SDK 类型。强依赖:core:model实线箭头通知的入参是NewsResource领域模型该模型定义于 core/model保证数据层、通知层共享同一份领域对象不产生模型拷贝。弱依赖:core:common虚线箭头该依赖仅在测试/调试场景使用属于testImplementation/debugImplementation级别的非发布依赖不会污染生产产物。图中配色还揭示了本仓库的模块分层约定android-library青色、jvm-library紫色、android-application绿色、android-feature橙色等这种统一的模块类型着色在 ModularizationLearningJourney.md 中有更系统的阐述。通知能力的接口抽象Notifier模块的对外门面是一个极简的接口 Notifier.ktinterface Notifier { fun postNewsNotifications(newsResources: ListNewsResource) }设计要点只暴露一个方法postNewsNotifications(ListNewsResource)语义聚焦于批量发布新闻更新通知。入参使用领域模型NewsResource定义于 core/model通知层不感知网络/数据库细节。通过接口而非具体类对外暴露为后续的多实现替换系统托盘通知、无操作空实现、测试替身提供了统一的接缝这正是依赖倒置原则在模块边界的落地。生产实现SystemTrayNotifier的系统托盘通知生产环境的默认实现是 SystemTrayNotifier.kt它被标注为Singleton并通过ApplicationContext注入Context。下面按流程拆解其实现细节。1. 运行时权限检查if (checkSelfPermission(this, permission.POST_NOTIFICATIONS) ! PERMISSION_GRANTED) { return }Android 13API 33起通知权限变为运行时权限POST_NOTIFICATIONS。该权限已在 AndroidManifest.xml 中声明manifest xmlns:androidhttp://schemas.android.com/apk/res/android uses-permission android:nameandroid.permission.POST_NOTIFICATIONS/ /manifest未授权时直接返回避免抛出SecurityException授权逻辑由应用层app 模块负责发起系统权限弹窗通知模块只做幂等检查。2. 数量截断与逐条通知private const val MAX_NUM_NOTIFICATIONS 5 val truncatedNewsResources newsResources.take(MAX_NUM_NOTIFICATIONS)最多只展示 5 条新闻防止同步一批新内容时刷屏。每条新闻通过createNewsNotification构建setSmallIcon(R.drawable.core_notifications_ic_nia_notification) .setContentTitle(newsResource.title) .setContentText(newsResource.content) .setContentIntent(newsPendingIntent(newsResource)) .setGroup(NEWS_NOTIFICATION_GROUP) .setAutoCancel(true)通知 ID 使用truncatedNewsResources[index].id.hashCode()保证同一条新闻的多次同步会覆盖旧通知而非无限堆积。3. 分组与 InboxStyle 汇总通知所有通知归入NEWS_NOTIFICATIONS分组并额外发送一条groupSummary汇总通知ID 固定为NEWS_NOTIFICATION_SUMMARY_ID 1val title getString( R.string.core_notifications_news_notification_group_summary, truncatedNewsResources.size, ) setContentTitle(title) .setContentText(title) .setSmallIcon(R.drawable.core_notifications_ic_nia_notification) .setStyle(newsNotificationStyle(truncatedNewsResources, title)) .setGroup(NEWS_NOTIFICATION_GROUP) .setGroupSummary(true)其中newsNotificationStyle使用NotificationCompat.InboxStyle逐行罗列各条新闻标题private fun newsNotificationStyle( newsResources: ListNewsResource, title: String, ): InboxStyle newsResources .fold(InboxStyle()) { inboxStyle, newsResource - inboxStyle.addLine(newsResource.title) } .setBigContentTitle(title) .setSummaryText(title)这样展开通知时用户可以看到类似5 news updates的标题与逐条新闻标题列表折叠时则合并为一条体验更整洁。4. 通知渠道Notification ChannelAndroid 8.0API 26起必须为通知指定渠道。createNewsNotification在每次构建前调用ensureNotificationChannelExists()private fun Context.ensureNotificationChannelExists() { if (VERSION.SDK_INT VERSION_CODES.O) return val channel NotificationChannel( NEWS_NOTIFICATION_CHANNEL_ID, getString(R.string.core_notifications_news_notification_channel_name), NotificationManager.IMPORTANCE_DEFAULT, ).apply { description getString(R.string.core_notifications_news_notification_channel_description) } NotificationManagerCompat.from(this).createNotificationChannel(channel) }渠道名称与描述分别取自 strings.xmlstring namecore_notifications_news_notification_channel_nameNews updates/string string namecore_notifications_news_notification_channel_descriptionThe latest updates on what\s new in Android/string string namecore_notifications_news_notification_group_summary%1$d news updates/string注意IMPORTANCE_DEFAULT的选择——若使用IMPORTANCE_HIGH会产生声音/震动打扰MIN/NONE则通知不会弹出提醒DEFAULT是在可见但不吵闹之间的平衡点。createNotificationChannel重复调用是幂等的因此每次构建前调用是安全且简单的做法。5. 点击跳转PendingIntent 深链每条通知的setContentIntent由newsPendingIntent生成private fun Context.newsPendingIntent( newsResource: NewsResource, ): PendingIntent? PendingIntent.getActivity( this, NEWS_NOTIFICATION_REQUEST_CODE, Intent().apply { action Intent.ACTION_VIEW data newsResource.newsDeepLinkUri() component ComponentName(packageName, TARGET_ACTIVITY_NAME) }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) private fun NewsResource.newsDeepLinkUri() $DEEP_LINK_BASE_PATH/$id.toUri()深链地址由模块顶部常量拼装private const val TARGET_ACTIVITY_NAME com.google.samples.apps.nowinandroid.MainActivity private const val DEEP_LINK_SCHEME_AND_HOST https://www.nowinandroid.apps.samples.google.com private const val DEEP_LINK_FOR_YOU_PATH foryou private const val DEEP_LINK_BASE_PATH $DEEP_LINK_SCHEME_AND_HOST/$DEEP_LINK_FOR_YOU_PATH const val DEEP_LINK_NEWS_RESOURCE_ID_KEY linkedNewsResourceId const val DEEP_LINK_URI_PATTERN $DEEP_LINK_BASE_PATH/{$DEEP_LINK_NEWS_RESOURCE_ID_KEY}要点TARGET_ACTIVITY_NAME显式指向MainActivity保证通知点击必定落到宿主 Activity。URI 形如https://www.nowinandroid.apps.samples.google.com/foryou/{newsResourceId}DEEP_LINK_NEWS_RESOURCE_ID_KEY即linkedNewsResourceId是携带新闻 ID 的查询参数名该常量被声明为const val供其他模块在解析深链时复用。FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE前者让新通知复用并刷新同一个 PendingIntent后者是 Android 12 对可变性的安全要求防止恶意篡改。空实现与测试替身NoOpNotifier与TestNotifierNoOpNotifier演示构建的空操作NoOpNotifier.kt 是接口的什么都不做实现internal class NoOpNotifier Inject constructor() : Notifier { override fun postNewsNotifications(newsResources: ListNewsResource) Unit }其 KDoc 明确说明用途Useful for tests and previews。它被绑定在demo 构建变体中见下节使演示/预览应用无需申请通知权限也不会弹通知。TestNotifier测试中的调用记录器在测试环境core/testing 提供了 TestNotifier.ktclass TestNotifier : Notifier { private val mutableAddedNewResources mutableListOfListNewsResource() val addedNewsResources: ListListNewsResource mutableAddedNewResources override fun postNewsNotifications(newsResources: ListNewsResource) { mutableAddedNewResources.add(newsResources) } }它把所有被通知的新闻列表累积起来供断言使用。在 OfflineFirstNewsRepositoryTest.kt 中可以看到多组针对Notifier调用行为的断言既有未订阅主题时不应调用L157、L204、L249也有只应通知包含已订阅主题的新资源L342以及全部资源已存在时不应通知L375。这些测试精确锁定了通知触发条件是接口抽象带来可测性的最佳例证。Hilt 注入与构建变体prod/demo 双实现切换:core:notifications通过两个同名不同源集的 Hilt Module 完成按构建变体绑定生产变体 NotificationsModule.ktprodModule InstallIn(SingletonComponent::class) internal abstract class NotificationsModule { Binds abstract fun bindNotifier( notifier: SystemTrayNotifier, ): Notifier }演示变体 NotificationsModule.ktdemoModule InstallIn(SingletonComponent::class) internal abstract class NotificationsModule { Binds abstract fun bindNotifier( notifier: NoOpNotifier, ): Notifier }使用Binds将具体实现绑定到Notifier接口Hilt 在SingletonComponent中维护单例。两个文件位于src/prod与src/demo源集与 Gradle 构建变体prod/demo见 app 的构建配置一一对应生产包发真实通知演示包静默无操作。消费者如OfflineFirstNewsRepository只依赖Notifier接口完全无感知地获得不同行为。通知触发时机数据层同步管线集成通知不是 UI 层直接触发的而是数据同步管线的一部分。在 OfflineFirstNewsRepository.kt 中Notifier被作为构造参数注入L53并在syncWith流程中触发if (hasOnboarded) { val addedNewsResources newsResourceDao.getNewsResources( useFilterTopicIds true, filterTopicIds followedTopicIds, useFilterNewsIds true, filterNewsIds changedIds.toSet() - existingNewsResourceIdsThatHaveChanged, ) .first() .map(PopulatedNewsResource::asExternalModel) if (addedNewsResources.isNotEmpty()) { notifier.postNewsNotifications( newsResources addedNewsResources, ) } }由此可以梳理出完整的业务规则仅当用户**已完成引导hasOnboarded**时才推送通知只通知用户已订阅主题下新增的新闻useFilterTopicIds filterTopicIds followedTopicIds只通知本次同步中新出现changedIds - 已存在的变更 ID且本地尚无的记录避免重复推送网络层与数据库层的具体数据来源在 core/network 与 core/database 中实现通知层对此完全解耦。深链消费ForYou 页面的联动通知点击后深链参数如何被消费feature/foryou/impl 中的 ForYouViewModel.kt 复用了通知模块导出的DEEP_LINK_NEWS_RESOURCE_ID_KEYval deepLinkedNewsResource savedStateHandle.getStateFlowString?( key DEEP_LINK_NEWS_RESOURCE_ID_KEY, null, ) .flatMapLatest { newsResourceId - if (newsResourceId null) { flowOf(emptyList()) } else { userNewsResourceRepository.observeAll( NewsResourceQuery( filterNewsIds setOf(newsResourceId), ), ) } } .map { it.firstOrNull() } .stateIn(...)onDeepLinkOpened负责在用户打开深链后清理状态并上报分析事件fun onDeepLinkOpened(newsResourceId: String) { if (newsResourceId deepLinkedNewsResource.value?.id) { savedStateHandle[DEEP_LINK_NEWS_RESOURCE_ID_KEY] null } analyticsHelper.logNewsDeepLinkOpen(newsResourceId newsResourceId) viewModelScope.launch { userDataRepository.setNewsResourceViewed(newsResourceId newsResourceId, viewed true) } }这条完整链路——通知模块生成深链 URI → MainActivity 接收 → Navigation 将参数写入SavedStateHandle→ForYouViewModel通过getStateFlow监听并定位对应新闻——正是通知点得进去、对得上号的闭环保证。对应的行为在 ForYouViewModelTest.ktL439 起中有测试覆盖预置savedStateHandle[DEEP_LINK_NEWS_RESOURCE_ID_KEY]后断言深链新闻被正确加载与消费。小图标资源通知小图标使用core_notifications_ic_nia_notification该资源在 drawable-anydpi-v24矢量图及 hdpi 至 xxhdpi 各密度位图中提供适配 Android 8 的任意密度矢量渲染与旧设备的位图回退是setSmallIcon正常运行的基础。小结从该模块可以借鉴的设计模式将 core/notifications/README.md 的依赖图与模块源码对照可以得到一组可复用的工程实践接口隔离 构建变体绑定Notifier接口 prod/demo双 Hilt Module让真实现与空实现的切换零成本演示包不申请权限、不弹通知。测试替身先行TestNotifier与数据层单元测试共同锁定通知触发条件把该不该通知的判定做成可回归的契约。通知体验的细节工程数量截断5 条上限、InboxStyle分组汇总、IMPORTANCE_DEFAULT渠道、FLAG_IMMUTABLE的 PendingIntent每一项都是对 Android 通知规范的教科书式落地。跨模块深链协作通过const val DEEP_LINK_NEWS_RESOURCE_ID_KEY导出参数名常量通知模块与 ForYou 页面共享同一套深链协议避免魔法字符串漂移。如果你正在设计自己应用的通知模块可直接以本模块为模板定义极简接口、按构建变体提供实现、在数据同步管线中触发、用深链闭环消费再配上一组TestNotifier断言——一套完整且可测试的通知体系就成型了。【免费下载链接】nowinandroidA fully functional Android app built entirely with Kotlin and Jetpack Compose项目地址: https://gitcode.com/GitHub_Trending/no/nowinandroid创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考