# 01 · 架构 > 回答 3 个问题:**模块怎么分?数据怎么流?为啥这样选?** --- ## 1. 模块划分 原则:**单 module + 按包分层**。MVP 不上多 module,等过 80 个 Kotlin 文件再拆 `:data` `:domain` `:ui` 三个 Gradle module。 ### 1.1 顶层包结构 ``` com.diary.news ├── DiaryNewsApp.kt # @HiltAndroidApp ├── MainActivity.kt # @AndroidEntryPoint,Compose host │ ├── data/ # ─── 数据层(对外接口 + 缓存)─── │ ├── api/ │ │ ├── ApiService.kt # Retrofit interface │ │ ├── dto/ # 12 个文件,与后端 schema 一一对应 │ │ ├── AuthInterceptor.kt # 加 Bearer │ │ └── TokenAuthenticator.kt # 401 自动 refresh │ ├── auth/ │ │ ├── TokenStore.kt # EncryptedSharedPreferences 封装 │ │ └── AuthRepository.kt │ ├── db/ │ │ ├── AppDatabase.kt │ │ ├── dao/ │ │ └── entity/ │ └── repository/ # ─── 业务仓储 ─── │ ├── ArticleRepository.kt │ ├── BookmarkRepository.kt │ └── SourceRepository.kt │ ├── domain/ # ─── 纯 Kotlin 业务模型(无 Android 依赖)─── │ └── model/ │ ├── Article.kt │ ├── Source.kt │ ├── Bookmark.kt │ └── ... │ ├── di/ # ─── Hilt 模块 ─── │ ├── NetworkModule.kt # Retrofit + OkHttp + Interceptor │ ├── DatabaseModule.kt # Room │ └── RepositoryModule.kt # @Binds 绑定 interface → impl │ └── ui/ # ─── 表现层(Compose)─── ├── theme/ │ ├── Theme.kt # Material3 + 蓝主题 │ ├── Color.kt │ └── Type.kt ├── nav/ │ └── AppNav.kt # Navigation Compose ├── login/ │ ├── LoginScreen.kt │ └── LoginViewModel.kt ├── feed/ │ ├── FeedScreen.kt │ ├── FeedViewModel.kt │ └── ArticlePagingSource.kt ├── article/ │ ├── ArticleScreen.kt │ ├── ArticleViewModel.kt │ └── ArticleTabs.kt # 评论/译文/原文 三段 Tab ├── bookmarks/ │ ├── BookmarksScreen.kt │ └── BookmarksViewModel.kt ├── sources/ │ ├── SourcesScreen.kt │ └── SourcesViewModel.kt └── common/ # 通用组件 ├── ArticleCard.kt ├── CommentaryBox.kt ├── EmptyState.kt ├── ErrorState.kt └── LoadingState.kt ``` ### 1.2 分层依赖关系(单向) ``` ui ──→ domain ──→ data ──→ (Retrofit / Room) ↑ └─ TokenStore(被 ApiService 和 AuthRepository 共用) ``` **关键约束**: - `domain` **不依赖 Android SDK**(纯 Kotlin,为了以后能抽出来 JVM 跑单元测试) - `data` 可以依赖 Android SDK(`Context` for EncryptedSharedPreferences / Room) - `ui` 唯一允许持有 `Context` 的层,通过 Hilt `@ApplicationContext` 注入 ### 1.3 为什么不现在分 Gradle module | 拆 module 的好处 | 不拆的好处(现阶段)| |---|---| | 强制分层 | 编译快(单 module 增量编译 5s,多 module 30s+)| | 增量 build 更快 | 心智负担低,新功能加上去就完了 | | 团队人多时并行开发 | 适合 1-2 人小项目 | **触发拆 module 的信号**:Kotlin 文件 > 80,或 build 时间 > 60s。 --- ## 2. 数据流 ### 2.1 三种典型场景 #### 场景 A:列表(分页 + 缓存无关) ``` FeedScreen └─ FeedViewModel.pager: Flow> └─ Pager(config = PagingConfig(pageSize =50)) └─ ArticlePagingSource └─ load(page): ApiService.listArticles(page, page_size =50) └─ 返回 ArticleListResponseDto └─ DTO → domain.Article mapper └─ PagingData → LazyColumn ``` **为什么用 Paging 3 而不是 LazyColumn + 自己管 offset**: - 预取(滑到底前 3 个就开始加载下一页) - 失败重试只重试那一页 - 内存上限自动管(滑走的会被回收) - 后续要换成 Room-backed `RemoteMediator` 也是几行代码 #### 场景 B:详情(单次拉取 + Room 缓存) ``` ArticleScreen(articleId) └─ ArticleViewModel.uiState: StateFlow └─ load(id): ├─ Room.bookmarkDao.findById(id) → 看是否已收藏(乐观更新用) ├─ ApiService.getArticle(id) └─ 合并 → UiState.Success(article, isBookmarked) ``` **Room 这里只缓存 bookmark 表**(不是 article 表)。原因见 §3。 #### 场景 C:登录 ``` LoginScreen └─ LoginViewModel.login(username, password) └─ AuthRepository.login() ├─ ApiService.login(LoginRequest) └─ TokenStore.save(access, refresh, expiresAt) └─ 触发 SharedFlow └─ MainActivity 监听 → 导航到 Feed ``` ### 2.2 异常 / 401 处理 ``` ApiService 任意调用 → OkHttp chain ├─ AuthInterceptor: 加 Authorization: Bearer └─ server 返回 401 ↓ OkHttp 调 TokenAuthenticator.authenticate(route, response) ├─ 检查:是不是 /auth/refresh 自己 401? 是 → 清 token,返回 null(放弃) ├─ synchronized(this) { ... } ← 单飞锁,防并发 refresh ├─ 当前 in-memory token ≠ request 里用的? 是 → 说明别的线程刚刷过,直接用新 token 重试 ├─ POST /auth/refresh { refresh_token } → 新 token pair ├─ TokenStore.save(新 token pair) └─ 重发原 request(自动) ``` ### 2.3 线程模型 | 操作 | 调度器 | 备注 | |---|---|---| | UI 渲染 | Main(自动)| `LaunchedEffect` / `collectAsState` | | Retrofit `suspend fun` | IO(Retrofit 自动切)| 别用 `withContext(Dispatchers.IO)` 包 | | Room 查询 | IO(Room 自动切)| 同上 | | Token 读写 SP | IO(用 `flow` API)| 别在主线程 read/write | | 图片解码 | Coil 自动 | 别手切线程 | **铁律**:**不要在 `Main` 干 IO**。Compose 的 `LaunchedEffect` 是 Main,默认别在里面跑阻塞调用。 --- ## 3. 本地存储策略 ### 3.1 三种数据,三种归宿 | 数据 | 存储位置 | 加密 | 生命周期 | |---|---|---|---| | `access_token` / `refresh_token` | EncryptedSharedPreferences | ✅ Keystore | 用户清 app 数据才消失 | | 收藏列表(`bookmarks`)| Room | ❌(本机隐私)| 卸载即清 | | 文章列表当前可见页 | 不缓存(每次拉)| — | — | | 已读标记(可选, MVP 不做)| Room | ❌ | 卸载即清 | ### 3.2 为什么 article 列表不缓存 - 后端每次返回最新翻译(LLM 增强是异步的,可能上次拉时是 pending,这次拉变 ok) - 缓存旧版会让用户疑惑"为啥我刷了半天内容没变" - Room 全文索引占空间,翻译正文动辄几 KB - 列表失败时显示 Snackbar 提示 + 空状态,够了 ### 3.3 Room schema(MVP 只要一张表) ```kotlin@Entity(tableName = "bookmarks_cache", primaryKeys = ["userId", "articleId"]) data class BookmarkCacheEntity( val userId: Long, val articleId: Long, val title: String, val titleZh: String?, val bodyZhText: String?, // 离线展示用 val publishedAt: String?, // ISO 8601 val sourceName: String, @ColumnInfo(name = "synced_at") val syncedAt: Long = System.currentTimeMillis(), ) @Dao interface BookmarkDao { @Query("SELECT * FROM bookmarks_cache WHERE userId = :userId ORDER BY synced_at DESC") fun observe(userId: Long): Flow> @Query("SELECT articleId FROM bookmarks_cache WHERE userId = :userId") suspend fun ids(userId: Long): List @Insert(onConflict = REPLACE) suspend fun upsertAll(items: List) @Query("DELETE FROM bookmarks_cache WHERE userId = :userId") suspend fun clear(userId: Long) } ``` **用途**: - 离线可看收藏列表(标题 + 译文正文) - 详情页打开时秒判 `isBookmarked`(不用每次都 GET /bookmarks) --- ## 4. 关键依赖选型理由 ### 4.1 Retrofit vs Ktor Client | 维度 | Retrofit | Ktor Client | |---|---|---| | Android 生态 | ✅ 10 年沉淀,Coil/Paging 都有 adapter | ❌ KMP 偏多,Android 教程少 | | 拦截器 | ✅ 完善(Authenticator / Interceptor)| ✅ 也有但 API 不一样 | | 团队熟悉度 | ✅ 大多数 Android 团队都会 | ❌ 新 | | 大小 | ~150KB | ~300KB | **结论**:Retrofit,成熟压倒一切。 ### 4.2 kotlinx.serialization vs Moshi | 维度 | kotlinx.serialization | Moshi | |---|---|---| | 编译期生成 | ✅ KSP(快)| ❌ KAPT(慢)| | 与 Kotlin 特性 | ✅ Sealed class / value class 一流 | ⚠️ 需要额外 adapter | | 学习曲线 | ✅ `@Serializable` 一行 | ⚠️ 需要手动 adapter | **结论**:kotlinx.serialization,我们后端 Pydantic 是声明式,客户端也用声明式,心智一致。 ### 4.3 Hilt vs Koin | 维度 | Hilt | Koin | |---|---|---| | 编译期 | ✅(编译时检查依赖图) | ❌ 运行时崩 | | 启动速度 | ✅ 零反射 | ⚠️ 启动慢 | | 与 Jetpack 集成 | ✅ ViewModel / Worker 原生 | ⚠️ 手动 `viewModel { }` | | 学习曲线 | ⚠️ 注解多 | ✅ DSL 友好 | **结论**:Hilt,Jetpack 集成 + 编译期校验。 ### 4.4 Coil vs Glide vs Fresco | 维度 | Coil | Glide | Fresco | |---|---|---|---| | Compose 支持 | ✅ 一等公民 | ⚠️ 用 GlideImage 包一层 | ❌ 没 Compose | | 包大小 | ~250KB | ~500KB | ~2MB | | 性能 | 高 | 高 | 极高(过度工程)| **结论**:Coil,Compose 原生。 ### 4.5 Paging 3 vs 手写 **结论**:Paging 3,理由见 §2.1。 ### 4.6 不用的东西 | 东西 | 为啥不用 | |---|---| | RxJava | 现在 Coroutines + Flow 完全够用 | | Dagger 2(纯)| Hilt 是 Dagger 的 Android 简化版 | | LiveData | StateFlow 完全替代 | | MMKV | EncryptedSharedPreferences 已够,MMKV 是 Tencent 的另选 | | Volley | 过时 | --- ## 5. 安全架构 ### 5.1 Token 流转 ``` LoginScreen 输入 ↓ HTTPS? ❌(我们 HTTP,见 network_security_config) ↓ 走明文 HTTP POST /auth/login ↓ server 返回 { access_token, refresh_token } ↓ TokenStore.save: ├─ MasterKey(AES256_GCM) 由 Android Keystore 保护 ├─ prefs.put("access", access_token) // AES256_SIV 加密 key ├─ prefs.put("refresh", refresh_token) // AES256_GCM 加密 value └─ 写盘 ↓ 后续每次请求 ↓ AuthInterceptor 读 SP → in-memory cache → 加 Bearer header ``` ### 5.2 Network Security Config(精准白名单) ```xml 207.57.129.228 ``` **为什么是白名单而不是全开 `cleartextTrafficPermitted="true"`**: - 万一以后引入了第三方 SDK(广告 / 统计),不会偷偷走 HTTP - 出问题时,review 更容易 - 业界 best practice ### 5.3 不做的事 - ❌ 不存用户密码(只存服务端返回的 token) - ❌ 不在 logcat 输出 token(debug build 用 Timber 自动 redact) - ❌ 不让 WebView 跑 JS(`body_zh_formatted` 是 HTML 片段,不是完整文档) - ❌ 不 root 检测(私人 app,没必要恶心自己) --- ## 6. 测试策略(MVP 阶段) | 层级 | 工具 | 覆盖率目标 | |---|---|---| | 单元测试(domain 层)| JUnit 5 + MockK | domain 逻辑 80%+ | | ViewModel 测试 | Turbine + MockK | 关键 ViewModel | | Repository 测试 | MockWebServer + Room in-memory | 网络 + DB 集成 | | UI 测试 | Compose Test | 登录 + 列表各 1 个 happy path | | 手工 | 真机刷一遍 | 7 天里程碑 DoD(见 04)| **先不写 UI 测试**,等主流程跑通再补。 --- ## 7. 未来扩展点 下面这些**不影响当前架构**,需要时再加: | 扩展 | 改动量 | 备注 | |---|---|---| | 多账号切换 | TokenStore 改成 `Map` | 1 天 | | 推送通知 | 加 Firebase 依赖 + Service | 半天,但要选 FCM 还是国内通道 | | Wear OS | 新 module,共用 `:data` `:domain` | 2-3 天 | | 深色主题 | `Theme.kt` 加 dark colorScheme | 2 小时 | | Tablet adaptive UI | WindowSizeClass + 列表/详情双栏 | 1 天 | | 平板分屏 | 同上 | 同上 | | 阅读历史 | Room 加 `history` 表 | 半天 | | 离线下载 | `DownloadManager` + Room | 1-2 天 |