新增 docs/android/ 目录: - README.md 总入口(快速上手 + 决策摘要 + 数据流) - 01-architecture.md 模块划分 + 数据流 + 选型理由 - 02-api-contract.md 每个接口的请求/响应 + DTO 字段映射 - 03-build-run.md Gradle/SDK/网络安全白名单/真机调试 - 04-milestones.md 7 天里程碑 + DoD + E2E 测试场景 新增 assets/: - logo/: 主图标 master + adaptive icon + 5 DPI launcher (方/圆) - splash/: 启动屏 logo + 完整背景预览 + 5 DPI 资源 - android_resources/: 集成所需的 XML(adaptive icon/主题/颜色/字符串/drawable/layout) - INTEGRATION.md 集成指南 - logo.svg + _make_logo.py 设计源 设计风格:参考用户提供的木质方块字母积木图,米色木纹底 + 深棕色字母 D,代表 'Diary',温暖私人日记感。 服务器体检:所有容器/API/DB/翻译主链路正常,TMT 本月已用 0.37%。 MaaS 备用通道上次已验证可用。
12 KiB
12 KiB
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(Contextfor 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 只要一张表)
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(精准白名单)
<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 天 |