Files
diary-news/docs/android/01-architecture.md
Mavis 02f0260dfc docs(android): 完整方案 + logo 资源 + 启动屏
新增 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 备用通道上次已验证可用。
2026-06-10 14:11:43 +08:00

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(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 只要一张表)

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 天