新增 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 备用通道上次已验证可用。
371 lines
12 KiB
Markdown
371 lines
12 KiB
Markdown
# 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 天 | |