Files
diary-news/docs/android/01-architecture.md

371 lines
12 KiB
Markdown
Raw Normal View History

# 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<PagingData<Article>>
└─ 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<ArticleUiState>
└─ 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<AuthEvent.LoggedIn>
└─ MainActivity 监听 → 导航到 Feed
```
### 2.2 异常 / 401 处理
```
ApiService 任意调用 → OkHttp chain
├─ AuthInterceptor: 加 Authorization: Bearer <access>
└─ 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<List<BookmarkCacheEntity>>
@Query("SELECT articleId FROM bookmarks_cache WHERE userId = :userId")
suspend fun ids(userId: Long): List<Long>
@Insert(onConflict = REPLACE)
suspend fun upsertAll(items: List<BookmarkCacheEntity>)
@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
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">207.57.129.228</domain>
</domain-config>
</network-security-config>
```
**为什么是白名单而不是全开 `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<userId, tokenPair>` | 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 天 |