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 备用通道上次已验证可用。
371
docs/android/01-architecture.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# 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 天 |
|
||||
585
docs/android/02-api-contract.md
Normal file
@@ -0,0 +1,585 @@
|
||||
# 02 · API 契约
|
||||
|
||||
> 后端:`http://207.57.129.228:3000/api/v1`
|
||||
>
|
||||
> 所有接口**走 Bearer JWT**(除 `auth/*` 外)。401 自动由 OkHttp `TokenAuthenticator` 处理。
|
||||
>
|
||||
> 本文档是**对后端 OpenAPI 的客户端镜像**,任何 schema 改动两边必须同步更新。
|
||||
|
||||
---
|
||||
|
||||
## 1. 鉴权(2 个)
|
||||
|
||||
### 1.1 `POST /auth/login`
|
||||
|
||||
**用途**:用户名密码登录,拿 token。
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"username": "owner",
|
||||
"password": "test1234"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200**:
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600
|
||||
}
|
||||
```
|
||||
|
||||
**DTO**:
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class LoginRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TokenPairDto(
|
||||
val access_token: String,
|
||||
val refresh_token: String,
|
||||
@SerialName("token_type") val tokenType: String = "bearer",
|
||||
@SerialName("expires_in") val expiresIn: Int,
|
||||
)
|
||||
```
|
||||
|
||||
### 1.2 `POST /auth/refresh`
|
||||
|
||||
**用途**:access 过期时换新的。
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200**:同 `login`。
|
||||
|
||||
**DTO**:
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class RefreshRequest(
|
||||
@SerialName("refresh_token") val refreshToken: String,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 我(2 个)
|
||||
|
||||
### 2.1 `GET /me`
|
||||
|
||||
**Response 200**:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"username": "owner",
|
||||
"email": null,
|
||||
"role": "owner",
|
||||
"created_at": "2026-01-15T08:00:00Z",
|
||||
"last_login_at": "2026-06-10T03:42:11Z"
|
||||
}
|
||||
```
|
||||
|
||||
**DTO**:
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class MeDto(
|
||||
val id: Long,
|
||||
val username: String,
|
||||
val email: String? = null,
|
||||
val role: String, // "owner" | "member"
|
||||
@SerialName("created_at") val createdAt: String,
|
||||
@SerialName("last_login_at") val lastLoginAt: String? = null,
|
||||
)
|
||||
```
|
||||
|
||||
### 2.2 `GET /me/usage`
|
||||
|
||||
**用途**:TMT 翻译配额使用情况。
|
||||
|
||||
**Response 200**:
|
||||
```json
|
||||
{
|
||||
"month_used": 128000,
|
||||
"month_quota": 5000000,
|
||||
"month_remaining": 4872000
|
||||
}
|
||||
```
|
||||
|
||||
**DTO**:
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class UsageDto(
|
||||
@SerialName("month_used") val monthUsed: Long,
|
||||
@SerialName("month_quota") val monthQuota: Long,
|
||||
@SerialName("month_remaining") val monthRemaining: Long,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 文章(2 个)
|
||||
|
||||
### 3.1 `GET /articles` — 列表(分页)
|
||||
|
||||
**Query 参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `page` | int | 否 | 1 | 页码(从 1 开始)|
|
||||
| `page_size` | int | 否 | 50 | 每页条数(1-200)|
|
||||
| `source` | string | 否 | - | 逗号分隔 source slug,如 `dw,nhk` |
|
||||
| `q` | string | 否 | - | 标题 / 正文模糊搜索 |
|
||||
| `lang` | enum | 否 | "both" | `src` / `zh` / `both` |
|
||||
| `since` | datetime | 否 | 24h 前 | 起始时间(UTC)|
|
||||
| `until` | datetime | 否 | - | 结束时间(UTC)|
|
||||
| `category` | string | 否 | - | LLM 分类 |
|
||||
| `starred_only` | bool | 否 | false | 只看收藏 |
|
||||
|
||||
**Response 200**:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": 510006,
|
||||
"source": {
|
||||
"id": 4,
|
||||
"name": "BBC 中文",
|
||||
"slug": "bbc-zh",
|
||||
"region": "UK"
|
||||
},
|
||||
"title": "Iran attacks Bahrain and Jordan...",
|
||||
"title_zh": "伊朗袭击巴林和约旦以报复美国对霍尔木兹的袭击",
|
||||
"body_zh_text": "...",
|
||||
"summary_zh": "...",
|
||||
"lang_src": "en",
|
||||
"translation_status": "ok",
|
||||
"category": "国际,军事",
|
||||
"published_at": "2026-06-10T03:30:00Z",
|
||||
"fetched_at": "2026-06-10T03:35:21Z",
|
||||
"image_url": "https://...",
|
||||
"image_ai_url": "https://...",
|
||||
"commentary": "中东局势进一步升级...",
|
||||
"commentary_status": "ok",
|
||||
"is_starred": false
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"page_size": 50,
|
||||
"total": 228,
|
||||
"total_pages": 5
|
||||
}
|
||||
```
|
||||
|
||||
**DTO**:
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class ArticleListResponseDto(
|
||||
val items: List<ArticleListItemDto>,
|
||||
val page: Int,
|
||||
@SerialName("page_size") val pageSize: Int,
|
||||
val total: Int,
|
||||
@SerialName("total_pages") val totalPages: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArticleListItemDto(
|
||||
val id: Long,
|
||||
val source: SourceBriefDto,
|
||||
val title: String,
|
||||
@SerialName("title_zh") val titleZh: String? = null,
|
||||
@SerialName("body_zh_text") val bodyZhText: String? = null,
|
||||
@SerialName("summary_zh") val summaryZh: String? = null,
|
||||
@SerialName("lang_src") val langSrc: String? = null,
|
||||
@SerialName("translation_status") val translationStatus: String, // "pending" | "ok" | "failed"
|
||||
val category: String? = null,
|
||||
@SerialName("published_at") val publishedAt: String? = null,
|
||||
@SerialName("fetched_at") val fetchedAt: String,
|
||||
@SerialName("image_url") val imageUrl: String? = null,
|
||||
@SerialName("image_ai_url") val imageAiUrl: String? = null,
|
||||
val commentary: String? = null,
|
||||
@SerialName("commentary_status") val commentaryStatus: String? = null,
|
||||
@SerialName("is_starred") val isStarred: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SourceBriefDto(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val slug: String,
|
||||
val region: String? = null,
|
||||
)
|
||||
```
|
||||
|
||||
### 3.2 `GET /articles/{id}` — 详情
|
||||
|
||||
**Response 200**(字段是 ListItem 的超集,再加正文):
|
||||
```json
|
||||
{
|
||||
"id": 510006,
|
||||
"source": { ... },
|
||||
"url": "https://www.bbc.com/...",
|
||||
"title": "Iran attacks...",
|
||||
"body_html": "<p>...</p>",
|
||||
"body_text": "Iran attacked Bahrain...",
|
||||
"title_zh": "伊朗袭击巴林...",
|
||||
"body_zh_html": "<p>...</p>",
|
||||
"body_zh_text": "伊朗袭击巴林和约旦...",
|
||||
"body_zh_formatted": "<div class='diary-para'>...</div>",
|
||||
"summary_zh": "...",
|
||||
"lang_src": "en",
|
||||
"author": "Jane Doe",
|
||||
"image_url": "...",
|
||||
"image_ai_url": "...",
|
||||
"translation_status": "ok",
|
||||
"translation_engine": "tencent",
|
||||
"translated_at": "2026-06-10T03:36:02Z",
|
||||
"category": "国际,军事",
|
||||
"format_status": "ok",
|
||||
"classify_status": "ok",
|
||||
"image_ai_status": "ok",
|
||||
"commentary_status": "ok",
|
||||
"commentary": "中东局势进一步升级...",
|
||||
"entities": {
|
||||
"PERSON": ["Trump", "Khamenei"],
|
||||
"ORG": ["UN", "EU"]
|
||||
},
|
||||
"sentiment": -0.42,
|
||||
"duplicate_of": null,
|
||||
"published_at": "2026-06-10T03:30:00Z",
|
||||
"fetched_at": "2026-06-10T03:35:21Z",
|
||||
"is_starred": true
|
||||
}
|
||||
```
|
||||
|
||||
**DTO**:
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class ArticleDetailDto(
|
||||
// 继承列表项所有字段
|
||||
val id: Long,
|
||||
val source: SourceBriefDto,
|
||||
val title: String,
|
||||
@SerialName("title_zh") val titleZh: String? = null,
|
||||
@SerialName("body_zh_text") val bodyZhText: String? = null,
|
||||
@SerialName("summary_zh") val summaryZh: String? = null,
|
||||
@SerialName("lang_src") val langSrc: String? = null,
|
||||
@SerialName("translation_status") val translationStatus: String,
|
||||
val category: String? = null,
|
||||
@SerialName("published_at") val publishedAt: String? = null,
|
||||
@SerialName("fetched_at") val fetchedAt: String,
|
||||
@SerialName("image_url") val imageUrl: String? = null,
|
||||
@SerialName("image_ai_url") val imageAiUrl: String? = null,
|
||||
val commentary: String? = null,
|
||||
@SerialName("commentary_status") val commentaryStatus: String? = null,
|
||||
@SerialName("is_starred") val isStarred: Boolean = false,
|
||||
// 详情独有
|
||||
val url: String,
|
||||
@SerialName("body_html") val bodyHtml: String? = null,
|
||||
@SerialName("body_text") val bodyText: String,
|
||||
@SerialName("body_zh_html") val bodyZhHtml: String? = null,
|
||||
@SerialName("body_zh_formatted") val bodyZhFormatted: String? = null,
|
||||
val author: String? = null,
|
||||
@SerialName("translation_engine") val translationEngine: String? = null,
|
||||
@SerialName("translated_at") val translatedAt: String? = null,
|
||||
@SerialName("format_status") val formatStatus: String? = null,
|
||||
@SerialName("classify_status") val classifyStatus: String? = null,
|
||||
@SerialName("image_ai_status") val imageAiStatus: String? = null,
|
||||
val entities: JsonObject? = null,
|
||||
val sentiment: Double? = null,
|
||||
@SerialName("duplicate_of") val duplicateOf: Long? = null,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 源(1 个)
|
||||
|
||||
### 4.1 `GET /sources`
|
||||
|
||||
**Response 200**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "DW 中文",
|
||||
"slug": "dw",
|
||||
"kind": "rss",
|
||||
"url": "https://rss.dw.com/...",
|
||||
"enabled": true,
|
||||
"region": "DE",
|
||||
"language_src": "de",
|
||||
"priority": 10,
|
||||
"fetch_interval_min": 30,
|
||||
"translate_to": "zh",
|
||||
"last_fetched_at": "2026-06-10T03:35:21Z",
|
||||
"last_status": "ok",
|
||||
"consecutive_failures": 0,
|
||||
"blocklist_tags": ["体育", "娱乐"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**DTO**:
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class SourceDto(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val slug: String,
|
||||
val kind: String,
|
||||
val url: String,
|
||||
val enabled: Boolean,
|
||||
val region: String? = null,
|
||||
@SerialName("language_src") val languageSrc: String? = null,
|
||||
val priority: Int,
|
||||
@SerialName("fetch_interval_min") val fetchIntervalMin: Int,
|
||||
@SerialName("translate_to") val translateTo: String,
|
||||
@SerialName("last_fetched_at") val lastFetchedAt: String? = null,
|
||||
@SerialName("last_status") val lastStatus: String? = null,
|
||||
@SerialName("consecutive_failures") val consecutiveFailures: Int = 0,
|
||||
@SerialName("blocklist_tags") val blocklistTags: List<String>? = null,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 收藏(3 个)
|
||||
|
||||
### 5.1 `GET /bookmarks` — 我的收藏
|
||||
|
||||
**Response 200**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 88,
|
||||
"article_id": 510006,
|
||||
"note": "中东局势长期观察",
|
||||
"created_at": "2026-06-10T05:12:30Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**DTO**:
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class BookmarkDto(
|
||||
val id: Long,
|
||||
@SerialName("article_id") val articleId: Long,
|
||||
val note: String? = null,
|
||||
@SerialName("created_at") val createdAt: String,
|
||||
)
|
||||
```
|
||||
|
||||
### 5.2 `POST /bookmarks` — 收藏
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"article_id": 510006,
|
||||
"note": "可选备注"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 201**:同 5.1。
|
||||
|
||||
**DTO**:
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class BookmarkCreateRequest(
|
||||
@SerialName("article_id") val articleId: Long,
|
||||
val note: String? = null,
|
||||
)
|
||||
```
|
||||
|
||||
### 5.3 `DELETE /bookmarks/{article_id}` — 取消收藏
|
||||
|
||||
**Response 204**(无 body)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 订阅(3 个)
|
||||
|
||||
### 6.1 `POST /subscriptions` — 订阅源
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"source_id": 1,
|
||||
"category_filter": ["国际", "科技"]
|
||||
}
|
||||
```
|
||||
|
||||
**Response 201**:
|
||||
```json
|
||||
{
|
||||
"id": 12,
|
||||
"source_id": 1,
|
||||
"category_filter": ["国际", "科技"],
|
||||
"created_at": "2026-06-10T05:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**DTO**:
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class SubscriptionCreateRequest(
|
||||
@SerialName("source_id") val sourceId: Long,
|
||||
@SerialName("category_filter") val categoryFilter: List<String>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SubscriptionDto(
|
||||
val id: Long,
|
||||
@SerialName("source_id") val sourceId: Long,
|
||||
@SerialName("category_filter") val categoryFilter: List<String>? = null,
|
||||
@SerialName("created_at") val createdAt: String,
|
||||
)
|
||||
```
|
||||
|
||||
### 6.2 `GET /subscriptions` — 我的订阅列表
|
||||
|
||||
**Response 200**:`SubscriptionDto[]`
|
||||
|
||||
### 6.3 `DELETE /subscriptions/{id}` — 取消订阅
|
||||
|
||||
**Response 204**。
|
||||
|
||||
---
|
||||
|
||||
## 7. 错误响应
|
||||
|
||||
### 7.1 4xx / 5xx 统一格式(RFC 7807)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "about:blank",
|
||||
"title": "Article not found",
|
||||
"status": 404,
|
||||
"instance": "http://207.57.129.228:3000/api/v1/articles/999999"
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 422 Validation Error
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "about:blank",
|
||||
"title": "Validation Error",
|
||||
"status": 422,
|
||||
"errors": [
|
||||
{
|
||||
"loc": ["body", "username"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing"
|
||||
}
|
||||
],
|
||||
"instance": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 客户端处理策略
|
||||
|
||||
```kotlin
|
||||
sealed class ApiError(message: String) : Exception(message) {
|
||||
object Unauthorized : ApiError("请重新登录")
|
||||
object NotFound : ApiError("资源不存在")
|
||||
data class Validation(val errors: List<String>) : ApiError("请求参数错误: $errors")
|
||||
data class Server(val status: Int, val title: String) : ApiError("服务器错误: $title")
|
||||
data class Network(cause: Throwable) : ApiError("网络错误: ${cause.message}")
|
||||
}
|
||||
|
||||
// 在 Repository 里捕获 HttpException:
|
||||
val response = try {
|
||||
api.listArticles(...)
|
||||
} catch (e: HttpException) {
|
||||
when (e.code()) {
|
||||
401 -> throw ApiError.Unauthorized
|
||||
404 -> throw ApiError.NotFound
|
||||
422 -> {
|
||||
val body = e.response()?.errorBody()?.string()
|
||||
throw ApiError.Validation(parseValidationErrors(body))
|
||||
}
|
||||
else -> throw ApiError.Server(e.code(), e.message())
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw ApiError.Network(e)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. DTO ↔ Domain 映射表
|
||||
|
||||
DTO 是网络层的事,domain model 是 UI 层的事。**永远不要让 DTO 漏到 UI**。
|
||||
|
||||
| DTO | Domain | 转换点 |
|
||||
|---|---|---|
|
||||
| `ArticleListItemDto` | `domain.model.Article` | `ArticlePagingSource.map()` |
|
||||
| `ArticleDetailDto` | `domain.model.ArticleDetail` | `ArticleRepository.getArticle()` |
|
||||
| `SourceDto` | `domain.model.Source` | `SourceRepository.list()` |
|
||||
| `BookmarkDto` | `domain.model.Bookmark` | `BookmarkRepository.list()` |
|
||||
| `MeDto` | `domain.model.User` | `AuthRepository.me()` |
|
||||
|
||||
**Domain model 示例**(UI 用):
|
||||
|
||||
```kotlin
|
||||
data class Article(
|
||||
val id: Long,
|
||||
val sourceName: String,
|
||||
val sourceSlug: String,
|
||||
val title: String,
|
||||
val titleZh: String?,
|
||||
val bodyZhText: String?,
|
||||
val summaryZh: String?,
|
||||
val langSrc: String?,
|
||||
val translationStatus: String,
|
||||
val categories: List<String>, // 已拆分逗号分隔
|
||||
val publishedAt: Instant?,
|
||||
val imageUrl: String?,
|
||||
val imageAiUrl: String?,
|
||||
val commentary: String?,
|
||||
val commentaryStatus: String?,
|
||||
val isStarred: Boolean,
|
||||
)
|
||||
|
||||
data class ArticleDetail(
|
||||
val article: Article,
|
||||
val url: String,
|
||||
val bodyText: String,
|
||||
val bodyHtml: String?,
|
||||
val bodyZhHtml: String?,
|
||||
val bodyZhFormatted: String?, // 优先用这个做正文渲染
|
||||
val author: String?,
|
||||
val translationEngine: String?,
|
||||
val formatStatus: String?,
|
||||
val classifyStatus: String?,
|
||||
val imageAiStatus: String?,
|
||||
val entities: Map<String, List<String>>?,
|
||||
val sentiment: Double?,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 不在 MVP 的接口(列出来,以后再用)
|
||||
|
||||
下面这些是 `admin` 系列,客户端不需要:
|
||||
|
||||
- `GET /admin/sources` `POST /admin/sources` `PATCH /admin/sources/{id}` `DELETE /admin/sources/{id}`
|
||||
- `POST /admin/refresh/{source_id}`
|
||||
- `POST /admin/translation/rerun/{article_id}`
|
||||
- `POST /admin/translation/quota/reset`
|
||||
- `GET /admin/health`
|
||||
- `GET /admin/llm/settings` `PUT /admin/llm/settings` `POST /admin/llm/settings/reset` `POST /admin/llm/settings/test`
|
||||
- `POST /admin/llm/enrich/{article_id}`
|
||||
|
||||
这些都是 **owner 自己后台用**,你会在 web 后台管,app 不碰。
|
||||
602
docs/android/03-build-run.md
Normal file
@@ -0,0 +1,602 @@
|
||||
# 03 · 构建与运行
|
||||
|
||||
> 从 0 到能装到真机跑的 APK,每一步都给具体命令。
|
||||
>
|
||||
> 假设你本地已经有:**Android Studio Hedgehog(2023.1.1)+** + **JDK 17** + **Android SDK 35** + **Kotlin 2.0.21**。
|
||||
|
||||
---
|
||||
|
||||
## 0. 环境清单
|
||||
|
||||
| 工具 | 版本 | 验证命令 |
|
||||
|---|---|---|
|
||||
| Android Studio | Hedgehog (2023.1.1) 或更新 | Help → About |
|
||||
| JDK | 17 | `java -version` |
|
||||
| Android SDK | 35 | SDK Manager 看 "Android 15.0" |
|
||||
| Gradle | 8.10+(Studio 自带) | `gradle --version` |
|
||||
| Kotlin | 2.0.21(项目自带)| 无所谓 |
|
||||
|
||||
**JDK 注意**:**JDK 17**,不是 11、不是 21。AGP 8.7 要求 JDK 17。
|
||||
|
||||
---
|
||||
|
||||
## 1. 新建工程
|
||||
|
||||
1. **File → New → New Project**
|
||||
2. 选 **Empty Activity (Compose)**
|
||||
3. 配置:
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| Name | `Diary News` |
|
||||
| Package name | `com.diary.news` |
|
||||
| Save location | `~/projects/diary-news-android/` |
|
||||
| Language | **Kotlin** |
|
||||
| Minimum SDK | **API 24 ("Nougat")** |
|
||||
| Build configuration language | **Kotlin DSL** |
|
||||
| ☑️ Use Version Catalog | **勾上**(用 `libs.versions.toml`)|
|
||||
|
||||
4. **Finish**
|
||||
|
||||
---
|
||||
|
||||
## 2. 改版本目录
|
||||
|
||||
打开 `gradle/libs.versions.toml`,**整体替换**为:
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
agp = "8.7.2"
|
||||
kotlin = "2.0.21"
|
||||
ksp = "2.0.21-1.0.27"
|
||||
|
||||
# AndroidX & Compose
|
||||
compose-bom = "2024.10.01"
|
||||
activity-compose = "1.9.3"
|
||||
lifecycle = "2.8.7"
|
||||
navigation-compose = "2.8.4"
|
||||
|
||||
# Network
|
||||
retrofit = "2.11.0"
|
||||
retrofit-kotlinx-converter = "1.0.0"
|
||||
okhttp = "4.12.0"
|
||||
kotlinx-serialization = "1.7.3"
|
||||
kotlinx-coroutines = "1.9.0"
|
||||
|
||||
# DI
|
||||
hilt = "2.52"
|
||||
hilt-navigation-compose = "1.2.0"
|
||||
|
||||
# Persistence
|
||||
room = "2.6.1"
|
||||
security-crypto = "1.1.0-alpha06"
|
||||
|
||||
# Paging
|
||||
paging = "3.3.4"
|
||||
paging-compose = "3.3.4"
|
||||
|
||||
# Image
|
||||
coil = "2.7.0"
|
||||
|
||||
# Test
|
||||
junit = "4.13.2"
|
||||
mockk = "1.13.13"
|
||||
turbine = "1.2.0"
|
||||
|
||||
[libraries]
|
||||
# Core
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
|
||||
# Compose
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" }
|
||||
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
|
||||
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
||||
androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
|
||||
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||
androidx-compose-material-icons = { module = "androidx.compose.material:material-icons-extended" }
|
||||
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
|
||||
|
||||
# Network
|
||||
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
|
||||
retrofit-kotlinx-serialization = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit-kotlinx-converter" }
|
||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
||||
|
||||
# DI
|
||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||
hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
|
||||
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-navigation-compose" }
|
||||
|
||||
# Persistence
|
||||
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
|
||||
room-paging = { module = "androidx.room:room-paging", version.ref = "room" }
|
||||
security-crypto = { module = "androidx.security:security-crypto-ktx", version.ref = "security-crypto" }
|
||||
|
||||
# Paging
|
||||
paging-runtime = { module = "androidx.paging:paging-runtime-ktx", version.ref = "paging" }
|
||||
paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging-compose" }
|
||||
|
||||
# Image
|
||||
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
|
||||
|
||||
# Test
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
||||
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 改 `app/build.gradle.kts`
|
||||
|
||||
整体替换为:
|
||||
|
||||
```kotlin
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.diary.news"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.diary.news"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
// debug 阶段先不上签名,直接装 debug APK
|
||||
// 真要 release,见 §6
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose BOM 统一管
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.icons)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
// Coroutines
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
// Network
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.kotlinx.serialization)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.logging)
|
||||
|
||||
// Serialization
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
// DI
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.hilt.navigation.compose)
|
||||
|
||||
// Persistence
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
implementation(libs.room.paging)
|
||||
ksp(libs.room.compiler)
|
||||
implementation(libs.security.crypto)
|
||||
|
||||
// Paging
|
||||
implementation(libs.paging.runtime)
|
||||
implementation(libs.paging.compose)
|
||||
|
||||
// Image
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
// Test
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.turbine)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 网络安全白名单(关键!)
|
||||
|
||||
### 4.1 `app/src/main/AndroidManifest.xml`
|
||||
|
||||
整体替换:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".DiaryNewsApp"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.DiaryNews"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
tools:targetApi="35"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.DiaryNews">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
```
|
||||
|
||||
### 4.2 `app/src/main/res/xml/network_security_config.xml`
|
||||
|
||||
新建文件:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!-- 默认所有域走系统 HTTPS(拒绝明文)-->
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
|
||||
<!-- 例外:只对后端 server 放行明文 HTTP -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">207.57.129.228</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
```
|
||||
|
||||
### 4.3 `app/src/main/res/xml/backup_rules.xml`(禁止备份 token)
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<exclude domain="sharedpref" path="auth_prefs.xml" />
|
||||
</full-backup-content>
|
||||
```
|
||||
|
||||
### 4.4 `app/src/main/res/xml/data_extraction_rules.xml`(Android 12+)
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="sharedpref" path="auth_prefs.xml" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="sharedpref" path="auth_prefs.xml" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. ProGuard / R8 规则(发布包必加)
|
||||
|
||||
`app/proguard-rules.pro`:
|
||||
|
||||
```proguard
|
||||
# Retrofit
|
||||
-keepattributes Signature, InnerClasses, EnclosingMethod
|
||||
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
|
||||
-keepattributes AnnotationDefault
|
||||
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
|
||||
-keep,allowobfuscation,allowshrinking class retrofit2.Response
|
||||
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
|
||||
|
||||
# kotlinx.serialization
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt
|
||||
|
||||
-keep,includedescriptorclasses class com.diary.news.**$$serializer { *; }
|
||||
-keepclassmembers class com.diary.news.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class com.diary.news.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# OkHttp
|
||||
-dontwarn org.conscrypt.**
|
||||
-dontwarn org.bouncycastle.**
|
||||
-dontwarn org.openjsse.**
|
||||
|
||||
# Hilt
|
||||
-keep class dagger.hilt.** { *; }
|
||||
-keep class * extends dagger.hilt.android.HiltAndroidApp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 第一次 Sync & Build
|
||||
|
||||
### 6.1 Sync Gradle
|
||||
|
||||
- Android Studio 顶部会弹"Gradle files have changed..." → **Sync Now**
|
||||
- 或菜单 **File → Sync Project with Gradle Files**
|
||||
- 等 5-15 分钟(下载依赖)
|
||||
|
||||
### 6.2 第一次 build
|
||||
|
||||
```bash
|
||||
# 命令行
|
||||
./gradlew assembleDebug
|
||||
|
||||
# 或 Studio 右上角 ▶️ 直接 Run
|
||||
```
|
||||
|
||||
预期:
|
||||
- `BUILD SUCCESSFUL`
|
||||
- 产物在 `app/build/outputs/apk/debug/app-debug.apk`
|
||||
|
||||
### 6.3 跑在模拟器
|
||||
|
||||
1. 工具栏 AVD Manager → Create Virtual Device → Pixel 7 + API 35
|
||||
2. 启动模拟器(等 30s-1min)
|
||||
3. Run ▶️
|
||||
|
||||
**模拟器坑**:
|
||||
- 模拟器自己的 IP 是 `10.0.2.2`,不是 `127.0.0.1`
|
||||
- 但我们的目标是 `207.57.129.228`(真实服务器),**模拟器直连外网就行,无需特殊配置**
|
||||
- 如果你只是想测本地后端,临时把 `network_security_config.xml` 改成 `10.0.2.2` 白名单
|
||||
|
||||
### 6.4 跑在真机
|
||||
|
||||
1. **设置 → 关于手机 → 连续点 7 次"版本号"** → 开启开发者模式
|
||||
2. **设置 → 系统 → 开发者选项 → 打开 USB 调试**
|
||||
3. USB 连电脑 → 手机弹"允许 USB 调试" → 确定
|
||||
4. Studio 右上角设备列表选你的真机 → Run ▶️
|
||||
|
||||
---
|
||||
|
||||
## 7. 配置服务器地址(可切换 debug / release)
|
||||
|
||||
**MVP 写死在 `BuildConfig`**。
|
||||
|
||||
在 `app/build.gradle.kts` 的 `defaultConfig` 加:
|
||||
|
||||
```kotlin
|
||||
defaultConfig {
|
||||
// ...
|
||||
buildConfigField("String", "API_BASE_URL", "\"http://207.57.129.228:3000/api/v1/\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
buildConfigField("String", "API_BASE_URL", "\"http://207.57.129.228:3000/api/v1/\"")
|
||||
}
|
||||
release {
|
||||
buildConfigField("String", "API_BASE_URL", "\"http://207.57.129.228:3000/api/v1/\"")
|
||||
// 后续要换 https,改这里 + 加证书
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
代码里用:
|
||||
|
||||
```kotlin
|
||||
@Provides @Singleton
|
||||
fun provideRetrofit(okHttp: OkHttpClient): Retrofit =
|
||||
Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.API_BASE_URL)
|
||||
.client(okHttp)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 调试技巧
|
||||
|
||||
### 8.1 OkHttp 日志
|
||||
|
||||
```kotlin
|
||||
@Provides @Singleton
|
||||
fun provideOkHttp(
|
||||
tokenStore: TokenStore,
|
||||
): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(AuthInterceptor(tokenStore))
|
||||
.addInterceptor(HttpLoggingInterceptor().apply {
|
||||
level = if (BuildConfig.DEBUG) Level.BODY else Level.NONE
|
||||
})
|
||||
.authenticator(TokenAuthenticator(tokenStore, ...))
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
```
|
||||
|
||||
`Level.BODY` 会在 Logcat 打印所有请求和响应体(包含 token!小心)。
|
||||
|
||||
**生产包务必改回 `Level.NONE`**。
|
||||
|
||||
### 8.2 Compose Preview
|
||||
|
||||
```kotlin
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ArticleCardPreview() {
|
||||
DiaryNewsTheme {
|
||||
ArticleCard(
|
||||
a = Article(
|
||||
id = 1,
|
||||
sourceName = "DW",
|
||||
sourceSlug = "dw",
|
||||
title = "Sample title",
|
||||
titleZh = "示例标题",
|
||||
bodyZhText = "示例正文...",
|
||||
...
|
||||
),
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 Layout Inspector
|
||||
|
||||
Studio → **Tools → Layout Inspector** → 选进程 → 实时看 Compose 树。
|
||||
|
||||
### 8.4 抓包
|
||||
|
||||
推荐 **Charles** 或 **mitmproxy**:
|
||||
- 模拟器:WiFi → 长按 → 修改网络 → 手动代理 → 127.0.0.1:8888
|
||||
- 真机:Charles Proxy + 安装证书
|
||||
- 注意:**mitmproxy 会让 HTTPS 失效**(因为加了第三方 CA),我们的 HTTP 不受影响
|
||||
|
||||
---
|
||||
|
||||
## 9. CI(可选,MVP 不强求)
|
||||
|
||||
如果你想每次 push 自动出 debug APK:
|
||||
|
||||
`.github/workflows/build.yml`:
|
||||
|
||||
```yaml
|
||||
name: Build APK
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Build
|
||||
run: ./gradlew assembleDebug
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app-debug
|
||||
path: app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 常见报错速查
|
||||
|
||||
| 报错 | 原因 | 解决 |
|
||||
|---|---|---|
|
||||
| `Cleartext HTTP traffic to 207.57.129.228 not permitted` | 没配 network_security_config | 装 §4 |
|
||||
| `Cannot resolve symbol 'hiltViewModel'` | 没加 `hilt-navigation-compose` | 装 §2 依赖 |
|
||||
| `Hilt: @AndroidEntryPoint ... missing binding` | Application 没 `@HiltAndroidApp` | 检查 `DiaryNewsApp.kt` |
|
||||
| `KSP not found` | KSP 版本与 Kotlin 不匹配 | 检查 `ksp` 版本号 |
|
||||
| `Plugin [id: 'com.google.devtools.ksp'] was not found` | settings.gradle.kts 没声明 | 在 `pluginManagement` 块加 |
|
||||
| `Composable invocations can only happen from the context of a @Composable function` | 在 `LaunchedEffect` 里调非 suspend 阻塞 | 加 `withContext(Dispatchers.IO)` |
|
||||
| `JSON decode error: Polymorphic serializer was not found` | 后端返回了 polymorphic 类型,DTO 没声明 | DTO 加 `@JsonClassDiscriminator` 或 sealed class |
|
||||
| `401 Unauthorized` 一片 | Token 过期 / 错 | 启动 app 看 logcat 的 OkHttp 日志,确认 refresh 接口通不通 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 发布签名(真要上架时再弄)
|
||||
|
||||
```bash
|
||||
# 生成 keystore
|
||||
keytool -genkey -v -keystore diary-news.jks -keyalg RSA -keysize 2048 \
|
||||
-validity 10000 -alias diary-news
|
||||
|
||||
# 在 ~/.gradle/gradle.properties 加
|
||||
DIARY_NEWS_STORE_FILE=diary-news.jks
|
||||
DIARY_NEWS_STORE_PASSWORD=...
|
||||
DIARY_NEWS_KEY_ALIAS=diary-news
|
||||
DIARY_NEWS_KEY_PASSWORD=...
|
||||
|
||||
# app/build.gradle.kts
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(providers.gradleProperty("DIARY_NEWS_STORE_FILE").get())
|
||||
storePassword = providers.gradleProperty("DIARY_NEWS_STORE_PASSWORD").get()
|
||||
keyAlias = providers.gradleProperty("DIARY_NEWS_KEY_ALIAS").get()
|
||||
keyPassword = providers.gradleProperty("DIARY_NEWS_KEY_PASSWORD").get()
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**MVP 阶段不弄**,直接 debug APK 装手机上跑就够了。
|
||||
273
docs/android/04-milestones.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# 04 · 7 天里程碑
|
||||
|
||||
> 目标:第 7 天能装一个 APK 到真机,跑通 **登录 → 列表 → 详情 → 收藏 → 离线缓存** 完整链路。
|
||||
>
|
||||
> 工作量估计基于:**全栈老哥**(你已经会 Kotlin/Compose 基础),按一天 3-4 小时有效开发时间算。
|
||||
>
|
||||
> **每个里程碑有明确 DoD**(Definition of Done)。不达 DoD 不算完成。
|
||||
|
||||
---
|
||||
|
||||
## 全局 DoD(贯穿 7 天)
|
||||
|
||||
- [ ] `./gradlew assembleDebug` 每次都 BUILD SUCCESSFUL
|
||||
- [ ] 真机/模拟器能装能跑,不闪退
|
||||
- [ ] `git commit` 节奏:每个里程碑一个 commit,信息写清楚
|
||||
- [ ] logcat 无 ERROR 级别的 crash
|
||||
- [ ] 没有 hardcoded 颜色 / 字体大小(全部走 `MaterialTheme`)
|
||||
|
||||
---
|
||||
|
||||
## Day 1 — 工程骨架 + 鉴权三件套
|
||||
|
||||
**目标**:能登录、能持久化 token、401 自动 refresh、空 UI 能跳转。
|
||||
|
||||
### 任务清单
|
||||
|
||||
| # | 任务 | 文件 | 估时 |
|
||||
|---|---|---|---|
|
||||
| 1 | 新建 Android Studio 工程,改 `libs.versions.toml` + `app/build.gradle.kts` | 见 03 | 30min |
|
||||
| 2 | 配 `network_security_config.xml` + 4 个 res 文件 | 见 03 §4 | 15min |
|
||||
| 3 | `DiaryNewsApp.kt`(`@HiltAndroidApp`)| `app/` | 5min |
|
||||
| 4 | `MainActivity.kt`(`@AndroidEntryPoint`,装 `AppNav`)| `app/` | 15min |
|
||||
| 5 | DTO 文件 12 个 | `data/api/dto/` | 1h |
|
||||
| 6 | `ApiService.kt` | `data/api/` | 30min |
|
||||
| 7 | `TokenStore.kt`(EncryptedSharedPreferences)| `data/auth/` | 30min |
|
||||
| 8 | `AuthInterceptor.kt` + `TokenAuthenticator.kt` | `data/api/` | 1h |
|
||||
| 9 | `NetworkModule.kt`(Hilt)| `di/` | 30min |
|
||||
| 10 | `LoginScreen.kt` + `LoginViewModel.kt` | `ui/login/` | 1h |
|
||||
| 11 | `AppNav.kt` 骨架(Login ↔ Feed)| `ui/nav/` | 15min |
|
||||
| 12 | `Theme.kt` + `Color.kt` + `Type.kt`(复用 web 的蓝 #2080f0)| `ui/theme/` | 30min |
|
||||
|
||||
### Day 1 DoD
|
||||
|
||||
- [ ] `./gradlew assembleDebug` BUILD SUCCESSFUL
|
||||
- [ ] 装到真机,启动能看到登录页
|
||||
- [ ] 输入 `owner / test1234`,点登录 → 跳到一个空白 Feed 页
|
||||
- [ ] 杀掉 app 再开 → 直接进 Feed 页(token 持久化生效)
|
||||
- [ ] 等 60min 后,任意接口请求 → 自动 refresh → 用户无感
|
||||
- [ ] logcat 里能看到 `OkHttp` 打印的请求日志(debug build)
|
||||
- [ ] 进 `设置 → 应用 → Diary News → 存储`,看不到明文 token(只在加密 SP 里)
|
||||
|
||||
### 验证方法
|
||||
|
||||
```bash
|
||||
# Day 1 验证清单(贴在 issue / PR 描述里):
|
||||
- [x] 登录成功,TokenStore 保存到 EncryptedSP
|
||||
- [x] 重启 app 自动进 Feed(读 SP 成功)
|
||||
- [x] 改密码 → 旧 token 在 server 失效 → app 收到 401 → 跳登录
|
||||
- [x] 等 access 过期(60min)→ 任意请求 → 自动 refresh → 200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Day 2 — Feed 列表(分页 + 卡片)
|
||||
|
||||
**目标**:能滚能动,看到真实的新闻列表,卡片视觉对齐 web。
|
||||
|
||||
### 任务清单
|
||||
|
||||
| # | 任务 | 文件 | 估时 |
|
||||
|---|---|---|---|
|
||||
| 1 | `ArticlePagingSource.kt` | `ui/feed/` | 30min |
|
||||
| 2 | `FeedViewModel.kt`(Paging 流)| `ui/feed/` | 30min |
|
||||
| 3 | `ArticleCard.kt`(对齐 web 视觉)| `ui/common/` | 1h |
|
||||
| 4 | `FeedScreen.kt`(LazyColumn + 加载状态)| `ui/feed/` | 1h |
|
||||
| 5 | 源筛选 + 关键词搜索(下拉 + 输入)| `ui/feed/` | 1h |
|
||||
| 6 | 空状态 / 错误状态 / 加载状态组件 | `ui/common/` | 30min |
|
||||
|
||||
### Day 2 DoD
|
||||
|
||||
- [ ] 列表能加载第一页(50 条)
|
||||
- [ ] 滑到底自动加载下一页(Paging 3 自动)
|
||||
- [ ] 卡片展示:源 / 语言 / 分类 tag / 时间 / 原标题(灰)/ 中标题 / 插图 / 译文正文摘要 / 评论钩子
|
||||
- [ ] 源筛选:选一个源,列表只剩该源
|
||||
- [ ] 关键词搜索:输入 "AI" → 列表过滤
|
||||
- [ ] 无网络时显示空状态 + 重试按钮
|
||||
- [ ] 滚得快时图片不卡(Coil 默认就 OK)
|
||||
|
||||
### 视觉对齐清单
|
||||
|
||||
对照 web `frontend/src/views/Feed.vue`:
|
||||
- 卡片圆角、间距、字号 1:1
|
||||
- 中文标题字号 18sp,原标题 13sp 灰
|
||||
- 评论钩子块背景 `#f6f8ff`,左边框 `#2080f0` 3dp
|
||||
|
||||
---
|
||||
|
||||
## Day 3 — 详情页(三段 Tab)
|
||||
|
||||
**目标**:点卡片进详情,看到完整译文 + 评论 + 原文。
|
||||
|
||||
### 任务清单
|
||||
|
||||
| # | 任务 | 文件 | 估时 |
|
||||
|---|---|---|---|
|
||||
| 1 | `ArticleDetailDto` → `domain.ArticleDetail` mapper | `data/` | 30min |
|
||||
| 2 | `ArticleViewModel.kt`(`StateFlow<UiState>`)| `ui/article/` | 30min |
|
||||
| 3 | `ArticleScreen.kt` 骨架 + TabRow | `ui/article/` | 30min |
|
||||
| 4 | `ArticleTabs.kt`:`CommentaryTab` / `TranslationTab` / `OriginalTab` | `ui/article/` | 1h |
|
||||
| 5 | `TranslationTab`:WebView 渲染 `body_zh_formatted`(无 JS)| `ui/article/` | 1h |
|
||||
| 6 | `OriginalTab`:WebView 渲染 `body_html`(无 JS)| `ui/article/` | 15min |
|
||||
| 7 | 详情页顶部卡片(标题 / 分类 / 时间 / 插图)| `ui/article/` | 30min |
|
||||
| 8 | 收藏按钮 ☆(Day 4 接 API,Day 3 先显示静态)| `ui/article/` | 15min |
|
||||
|
||||
### Day 3 DoD
|
||||
|
||||
- [ ] 点列表卡片 → 进详情页
|
||||
- [ ] 顶部展示:原标题(灰)/ 中标题(主)/ 插图 / 分类 tag / 发布时间 / 作者
|
||||
- [ ] 三个 Tab 可切换:评论 / 译文 / 原文
|
||||
- [ ] 译文 Tab 用 `body_zh_formatted`,如果没有就 fallback 到 `body_zh_text`
|
||||
- [ ] WebView 不开 JS(验证方式:`runJavaScript` 调用失败)
|
||||
- [ ] 详情页旋转屏幕 / 切换深色模式不崩
|
||||
|
||||
---
|
||||
|
||||
## Day 4 — 收藏(乐观更新 + Room 缓存)
|
||||
|
||||
**目标**:能收藏 / 取消,UI 秒响应,失败可回滚。
|
||||
|
||||
### 任务清单
|
||||
|
||||
| # | 任务 | 文件 | 估时 |
|
||||
|---|---|---|---|
|
||||
| 1 | Room:`AppDatabase.kt` + `BookmarkDao.kt` + `BookmarkCacheEntity` | `data/db/` | 1h |
|
||||
| 2 | `BookmarkRepository.kt`(乐观更新)| `data/repository/` | 1h |
|
||||
| 3 | `DatabaseModule.kt`(Hilt)| `di/` | 15min |
|
||||
| 4 | 详情页收藏按钮接 `ArticleViewModel.toggleBookmark()` | `ui/article/` | 30min |
|
||||
| 5 | `BookmarksScreen.kt` + `BookmarksViewModel.kt` | `ui/bookmarks/` | 1h |
|
||||
| 6 | 离线显示:无网络时用 Room 缓存渲染 | `ui/bookmarks/` | 30min |
|
||||
|
||||
### Day 4 DoD
|
||||
|
||||
- [ ] 详情页点 ☆ → 立刻变实心(乐观更新)
|
||||
- [ ] 后台调 `POST /bookmarks`,失败时 → 回滚 ☆ + Snackbar "收藏失败,已撤销"
|
||||
- [ ] 收藏列表页能看到所有收藏
|
||||
- [ ] 离线时收藏列表仍可看(读 Room 缓存)
|
||||
- [ ] 滑动列表时无卡顿(用 `LazyColumn` + `key`)
|
||||
|
||||
---
|
||||
|
||||
## Day 5 — 源 + 订阅
|
||||
|
||||
**目标**:能看所有源、订阅 / 取消订阅。
|
||||
|
||||
### 任务清单
|
||||
|
||||
| # | 任务 | 文件 | 估时 |
|
||||
|---|---|---|---|
|
||||
| 1 | `SourceRepository.kt` | `data/repository/` | 30min |
|
||||
| 2 | `SourcesScreen.kt` + `SourcesViewModel.kt` | `ui/sources/` | 1h |
|
||||
| 3 | 订阅按钮 + `POST/DELETE /subscriptions` | `ui/sources/` | 1h |
|
||||
| 4 | 底部导航栏:Feed / Sources / Bookmarks | `ui/nav/` | 1h |
|
||||
|
||||
### Day 5 DoD
|
||||
|
||||
- [ ] 底部三 Tab:Feed / Sources / Bookmarks
|
||||
- [ ] Sources 页能看到所有 enabled 源,带订阅状态
|
||||
- [ ] 点订阅按钮 → 立刻变化 + 后台请求
|
||||
- [ ] 失败 Snackbar 提示 + 回滚
|
||||
- [ ] Feed 页源筛选多选能跨 Tab 状态保留(navigation 状态管理)
|
||||
|
||||
---
|
||||
|
||||
## Day 6 — 主题 + 通用打磨
|
||||
|
||||
**目标**:视觉统一、空状态友好、加载体验好。
|
||||
|
||||
### 任务清单
|
||||
|
||||
| # | 任务 | 文件 | 估时 |
|
||||
|---|---|---|---|
|
||||
| 1 | Material3 主题:浅色 + 深色(Dynamic Color,Android 12+)| `ui/theme/` | 1h |
|
||||
| 2 | 自定义启动屏 + 应用图标 | `res/` | 30min |
|
||||
| 3 | 通用加载 / 空 / 错误组件统一风格 | `ui/common/` | 30min |
|
||||
| 4 | 下拉刷新(`SwipeRefreshLayout` / `PullToRefreshContainer`)| `ui/feed/` | 30min |
|
||||
| 5 | 网络状态监听(ConnectivityManager)| `data/` | 30min |
|
||||
| 6 | 离线条 / Snackbar 提示 | `ui/common/` | 30min |
|
||||
| 7 | 长按卡片 → 分享 / 复制 URL 菜单 | `ui/common/` | 30min |
|
||||
|
||||
### Day 6 DoD
|
||||
|
||||
- [ ] 切深色模式 → 全 app 颜色自动切(阅读体验好)
|
||||
- [ ] 启动屏不闪白(用 SplashScreen API)
|
||||
- [ ] 列表支持下拉刷新
|
||||
- [ ] 飞行模式下进入 app → 各页有合理提示
|
||||
- [ ] 长按文章卡片弹出菜单:复制链接 / 在浏览器打开 / 分享
|
||||
|
||||
---
|
||||
|
||||
## Day 7 — 收尾 + 真机端到端测试 + APK 打包
|
||||
|
||||
**目标**:出第一个 release-ready APK,完整跑一遍所有功能。
|
||||
|
||||
### 任务清单
|
||||
|
||||
| # | 任务 | 文件 | 估时 |
|
||||
|---|---|---|---|
|
||||
| 1 | `README.md`(装 app 说明 + 调试指南)| 根目录 | 30min |
|
||||
| 2 | ProGuard / R8 配置 + release build 验证 | `proguard-rules.pro` | 1h |
|
||||
| 3 | 真机端到端测试(用 [e2e-checklist.md](#) 列表)| — | 2h |
|
||||
| 4 | crash 报告接入(可选:Sentry / Firebase Crashlytics)| — | 1h |
|
||||
| 5 | 修最后发现的 bug | — | 1h |
|
||||
|
||||
### Day 7 DoD
|
||||
|
||||
- [ ] `./gradlew assembleRelease` 出 `app-release-unsigned.apk`
|
||||
- [ ] debug APK 跑完下面所有场景,全过
|
||||
|
||||
### E2E 测试场景清单
|
||||
|
||||
- [ ] 第一次启动 → 登录页
|
||||
- [ ] 登录成功 → 进 Feed
|
||||
- [ ] 列表能加载,滑到底加载更多
|
||||
- [ ] 源筛选 / 关键词搜索生效
|
||||
- [ ] 点卡片进详情,三段 Tab 都能切
|
||||
- [ ] 收藏 / 取消收藏 UI 秒响应,后台请求成功
|
||||
- [ ] 收藏页能看到刚收藏的
|
||||
- [ ] 杀进程,重启,直接进 Feed(token 持久化)
|
||||
- [ ] 飞行模式启动,各页空状态 + 离线缓存(收藏)仍可用
|
||||
- [ ] access 过期后任意请求,自动 refresh,UI 无感
|
||||
- [ ] 改密码,旧 token → app 跳登录
|
||||
|
||||
---
|
||||
|
||||
## 风险 & 应对
|
||||
|
||||
| 风险 | 概率 | 影响 | 应对 |
|
||||
|---|---|---|---|
|
||||
| Compose / Kotlin 版本不兼容,build 失败 | 中 | 高 | 严格用本文档指定版本;失败先查 [Compose-Kotlin Compatibility Map](https://developer.android.com/jetpack/androidx/releases/compose-kotlin) |
|
||||
| 后端 schema 改了,app 跑不起来 | 中 | 中 | 启动时 catch `SerializationException`,跳错误页 + 重启按钮 |
|
||||
| 真机 7.0 / 7.1 系统太老,某些 API 没有 | 低 | 中 | 用 AndroidX 兼容层;遇到问题查 [API 24 compat matrix](https://developer.android.com/training/backward-compatible-support) |
|
||||
| 网络安全白名单加错,全 app 报错 | 低 | 高 | 第 1 次 build 一定先跑清单 Day 1 DoD |
|
||||
| 11 章(ProGuard)没做就 release,接口全找不到 | 中 | 高 | Day 7 必须先跑 `./gradlew assembleRelease` 再装机 |
|
||||
| EncryptedSharedPreferences 在某些定制 ROM 上崩 | 极低 | 中 | catch `GeneralSecurityException`,fallback 引导用户重新登录 |
|
||||
|
||||
---
|
||||
|
||||
## 后续迭代方向(7 天后看)
|
||||
|
||||
| 优先级 | 功能 | 估时 |
|
||||
|---|---|---|
|
||||
| P1 | FCM 推送(新文章到时通知)| 2-3 天 |
|
||||
| P1 | 阅读历史(本地)| 半天 |
|
||||
| P2 | 平板 adaptive(WindowSizeClass + 双栏)| 1-2 天 |
|
||||
| P2 | 离线下载包(整周报导出)| 2-3 天 |
|
||||
| P3 | Wear OS 端 | 1 周 |
|
||||
| P3 | 主屏 widget | 2-3 天 |
|
||||
| P3 | 多账号切换 | 1 天 |
|
||||
|
||||
---
|
||||
|
||||
## 任务量快速核算
|
||||
|
||||
| Day | 估时 | 主要内容 |
|
||||
|---|---|---|
|
||||
| 1 | 7h | 工程 + 鉴权 |
|
||||
| 2 | 4.5h | Feed + 分页 + 卡片 |
|
||||
| 3 | 4.5h | 详情 + WebView |
|
||||
| 4 | 4h | 收藏 + Room |
|
||||
| 5 | 3.5h | 源 + 订阅 + 底导 |
|
||||
| 6 | 4h | 主题 + 通用 |
|
||||
| 7 | 5.5h | 测试 + APK |
|
||||
| **总计** | **~33h** | 7 个有效工作日 |
|
||||
179
docs/android/README.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Diary News — Android App
|
||||
|
||||
> 私人新闻聚合器的 Android 客户端,读 FastAPI 后端。
|
||||
>
|
||||
> **状态**:方案阶段(未开工)。本文档是开工前的最终蓝图,任何代码改动必须先对齐这里的约定。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话总览
|
||||
|
||||
- **后端**:`http://207.57.129.228:3000/api/v1`(私有 IP 直连,明文 HTTP)
|
||||
- **客户端**:Kotlin + Jetpack Compose,单 module
|
||||
- **认证**:Bearer JWT,access 60min,自动 refresh
|
||||
- **离线**:列表分页缓存 + 收藏本地库 + Token 加密存储
|
||||
- **目标**:7 天出第一个能跑通登录 → 列表 → 详情 → 收藏的 APK
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档索引(按开工顺序读)
|
||||
|
||||
| 序 | 文档 | 作用 | 何时读 |
|
||||
|---|---|---|---|
|
||||
| 1 | [01-architecture.md](01-architecture.md) | 模块划分 + 数据流 + 依赖选型理由 | 开工前 30min |
|
||||
| 2 | [02-api-contract.md](02-api-contract.md) | 每个接口的请求/响应 + DTO 字段映射表 | 写 DTO 时对照 |
|
||||
| 3 | [03-build-run.md](03-build-run.md) | Gradle / SDK / network security / 真机调试 | 第一次 build 前 |
|
||||
| 4 | [04-milestones.md](04-milestones.md) | 7 天里程碑拆分 + DoD | 每天开工前看当天任务 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 5 分钟决策摘要(免读细节直接用)
|
||||
|
||||
| 维度 | 选择 | 替代方案(及为啥不选)|
|
||||
|---|---|---|
|
||||
| 语言 | Kotlin 2.0.21 | — |
|
||||
| UI | Jetpack Compose (Material3) | ❌ XML View(老)| ❌ RN/Flutter(项目太轻)|
|
||||
| 网络 | Retrofit 2.11 + OkHttp 4.12 | ❌ Ktor Client(生态薄)|
|
||||
| 序列化 | kotlinx.serialization 1.7.3 | ❌ Moshi(更重)|
|
||||
| DI | Hilt 2.52 | ❌ Koin(运行时,启动慢)|
|
||||
| 分页 | Paging 3.3.4 | ❌ 手写 LazyColumn + offset |
|
||||
| 图片 | Coil 2.7 | ❌ Glide(Compose 集成弱)|
|
||||
| 路由 | Navigation Compose 2.8.4 | — |
|
||||
| 加密 | security-crypto 1.1.0-alpha06(EncryptedSharedPreferences)| — |
|
||||
| 本地 DB | Room 2.6.1 | — |
|
||||
| minSdk | 24 (Android 7.0) | ❌ 26(放弃 7.0/7.1 ~3% 用户)|
|
||||
| targetSdk | 35 (Android 15) | — |
|
||||
| compileSdk | 35 | — |
|
||||
| AGP | 8.7.2 | — |
|
||||
| API base | `http://207.57.129.228:3000/api/v1` | ❌ HTTPS(当前 server 无证书)|
|
||||
|
||||
---
|
||||
|
||||
## 3. 顶层目录(最终落地的样子)
|
||||
|
||||
```
|
||||
diary-news-android/ # ← 独立 Git 仓库(不要混进 diary-news)
|
||||
├── settings.gradle.kts├── build.gradle.kts # root
|
||||
├── gradle/
|
||||
│ ├── libs.versions.toml # 集中版本
|
||||
│ └── wrapper/
|
||||
├── app/
|
||||
│ ├── build.gradle.kts│ ├── proguard-rules.pro│ └── src/main/
|
||||
│ ├── AndroidManifest.xml
|
||||
│ ├── res/ # xml + values + mipmap
|
||||
│ └── java/com/diary/news/
|
||||
│ ├── DiaryNewsApp.kt # @HiltAndroidApp
|
||||
│ ├── MainActivity.kt│ ├── data/ # api/auth/db/repository
|
||||
│ ├── domain/ # 业务 model
|
||||
│ ├── di/ # Hilt modules
|
||||
│ └── ui/ # theme/nav/login/feed/article/bookmarks/sources/common
|
||||
└── README.md
|
||||
```
|
||||
|
||||
> **仓库策略**:Android app 单独建 Git 仓库 `diary-news-android`,不要和 `diary-news`(后端 + web)混。原因是 release 节奏 / CI / 依赖管理天然不同。
|
||||
|
||||
---
|
||||
|
||||
## 4. 端到端数据流(登录 → 刷列表 → 打开详情 → 收藏)
|
||||
|
||||
```
|
||||
1. 启动
|
||||
DiaryNewsApp.onCreate
|
||||
→ Hilt 初始化
|
||||
→ MainActivity 检查 TokenStore:有 access 且未过期 → 直接进 Feed
|
||||
→ 无 / 过期 → 进 LoginScreen
|
||||
|
||||
2. 登录
|
||||
LoginScreen → LoginViewModel.login()
|
||||
→ POST /auth/login { username, password }
|
||||
→ 拿到 { access_token, refresh_token, expires_in }
|
||||
→ TokenStore.save() (EncryptedSharedPreferences)
|
||||
→ 跳 FeedScreen
|
||||
|
||||
3. 列表
|
||||
FeedScreen → FeedViewModel
|
||||
→ Pager + ArticlePagingSource(api::listArticles)
|
||||
→ ApiService.listArticles(page, page_size, ...)
|
||||
→ AuthInterceptor 加 Authorization: Bearer <access>
|
||||
→ 返回 ArticleListResponseDto
|
||||
→ Paging 流到 LazyColumn
|
||||
→ 每一项 ArticleCard 渲染
|
||||
|
||||
4. 详情
|
||||
点 ArticleCard → ArticleScreen(articleId)
|
||||
→ ArticleViewModel.load(id)
|
||||
→ ApiService.getArticle(id)
|
||||
→ 三段 Tab:评论(commentary)/译文(body_zh_formatted)/原文(body_html)
|
||||
|
||||
5. 收藏
|
||||
详情页点 ☆ 按钮 → ArticleViewModel.toggleBookmark()
|
||||
→ POST /bookmarks { article_id } 或 DELETE /bookmarks/{id}
|
||||
→ 乐观更新 UI(立刻变实心★,失败再回滚 + Snackbar)
|
||||
|
||||
6. 401 自动 refresh
|
||||
任意接口返回 401
|
||||
→ OkHttp TokenAuthenticator 拦截
|
||||
→ 拿 refresh_token 调 /auth/refresh
|
||||
→ 拿到新 access → 重发原请求
|
||||
→ 用户完全无感
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 不要做的事(踩坑清单)
|
||||
|
||||
| 坑 | 说明 | 正确做法 |
|
||||
|---|---|---|
|
||||
| ❌ 把 access_token 存普通 SharedPreferences | root 手机秒读 | `EncryptedSharedPreferences` + Keystore |
|
||||
| ❌ 在主线程调 Retrofit | ANR | `suspend fun` + ViewModelScope(自动主线程安全)|
|
||||
| ❌ 在每次请求前同步读 token | 阻塞 UI | TokenStore 缓存到内存,只在 save/load 时动 SP |
|
||||
| ❌ refresh 接口并发触发 N 次 | 触发限流 / 死锁 | `synchronized(this)` 单飞锁 |
|
||||
| ❌ 全量缓存所有文章 | DB 撑爆,启动慢 | 只缓存当前可见页 |
|
||||
| ❌ WebView 开 JS | XSS 风险 | `settings.javaScriptEnabled = false` |
|
||||
| ❌ 信任 HTTPS 证书所有 CA | 中间人 | 默认 `system` trust anchor,不动 |
|
||||
| ❌ 让 HTTP 走全网 | 不安全 | `network_security_config.xml` 白名单单一 IP |
|
||||
| ❌ 在 ViewModel 里持有 Context | 内存泄漏 | 用 `@HiltAndroidApp` / `AndroidEntryPoint`,Context 通过 Hilt 注入 |
|
||||
| ❌ ProGuard 不留 keep 规则就 release | retrofit 接口全找不到 | 详见 03-build-run.md §5 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 启动指令(开工第一步)
|
||||
|
||||
1. **Android Studio Hedgehog**(2023.1.1)+ → `File → New → New Project → Empty Activity (Compose)`
|
||||
2. **包名**:`com.diary.news`
|
||||
3. **Application name**:`Diary News`
|
||||
4. **Min SDK**:24 / **Target SDK**:35
|
||||
5. **Kotlin DSL** + **Version Catalog**(`gradle/libs.versions.toml`)
|
||||
6. 改完 `gradle/libs.versions.toml` 后第一次 Sync —— 视网络,5-15 分钟
|
||||
7. 跑 `gradlew assembleDebug` 出第一个 debug APK
|
||||
8. 真机或模拟器装上,网络选 **宿主机的桥接**(模拟器用 `10.0.2.2:3000` 临时绕开,真机直接走 server IP)
|
||||
|
||||
完整步骤见 [03-build-run.md](03-build-run.md)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 与 web 端的关系
|
||||
|
||||
| 项 | web (Vue) | Android (Kotlin) |
|
||||
|---|---|---|
|
||||
| 主题色 | `#2080f0` | 同 |
|
||||
| 字体 | 系统字体 + 14px/13px | Material3 Typography scale |
|
||||
| 卡片布局 | 标题 → 译标 → 摘要 → 评论钩子 | 同(插图在中间)|
|
||||
| 列表分页 | 12345 页码 (NPagination)| 滚动加载(Paging 3)|
|
||||
| 详情页布局 | 评论 / 译文 / 原文 三段 | 同(改用 Tab) |
|
||||
| 鉴权 | localStorage 存 token | EncryptedSharedPreferences |
|
||||
|
||||
视觉与交互**保持一致**,不要做出两个产品的分裂感。
|
||||
|
||||
---
|
||||
|
||||
## 8. 后续可能加的东西(不在 MVP)
|
||||
|
||||
- 推送通知(FCM / 极光)
|
||||
- 离线下载包(整本周报导出 PDF)
|
||||
- 阅读历史(本地,不上服务端)
|
||||
- 暗色主题(Material3 Dynamic Color)
|
||||
- 主屏幕 widget(显示今日头条)
|
||||
- Wear OS 端(以后再说)
|
||||
|
||||
> 原则:**MVP 先能跑,再加料**。每加一项功能,先回到 [01-architecture.md](01-architecture.md) 看会不会破坏现有分层。
|
||||
230
docs/android/assets/INTEGRATION.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Logo + 启动屏集成指南
|
||||
|
||||
> 把 `assets/` 下所有产物拷到你的 Android Studio 工程目录里。
|
||||
>
|
||||
> 主要文件清单 + 拷贝目标位置。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文件清单
|
||||
|
||||
### 1.1 应用图标
|
||||
|
||||
| 源文件 | 目标位置(Android Studio 项目)|
|
||||
|---|---|
|
||||
| `assets/logo/ic_launcher_foreground.png` | `app/src/main/res/mipmap-anydpi-v26/ic_launcher_foreground.png`(覆盖)|
|
||||
| `assets/logo/ic_launcher_background.png` | `app/src/main/res/mipmap-anydpi-v26/ic_launcher_background.png`(覆盖)|
|
||||
| `assets/logo/mipmap-mdpi/ic_launcher.png` | `app/src/main/res/mipmap-mdpi/ic_launcher.png`(覆盖)|
|
||||
| `assets/logo/mipmap-mdpi/ic_launcher_round.png` | `app/src/main/res/mipmap-mdpi/ic_launcher_round.png`(覆盖)|
|
||||
| `assets/logo/mipmap-hdpi/ic_launcher.png` | `app/src/main/res/mipmap-hdpi/ic_launcher.png`(覆盖)|
|
||||
| `assets/logo/mipmap-hdpi/ic_launcher_round.png` | `app/src/main/res/mipmap-hdpi/ic_launcher_round.png`(覆盖)|
|
||||
| `assets/logo/mipmap-xhdpi/ic_launcher.png` | `app/src/main/res/mipmap-xhdpi/ic_launcher.png`(覆盖)|
|
||||
| `assets/logo/mipmap-xhdpi/ic_launcher_round.png` | `app/src/main/res/mipmap-xhdpi/ic_launcher_round.png`(覆盖)|
|
||||
| `assets/logo/mipmap-xxhdpi/ic_launcher.png` | `app/src/main/res/mipmap-xxhdpi/ic_launcher.png`(覆盖)|
|
||||
| `assets/logo/mipmap-xxhdpi/ic_launcher_round.png` | `app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png`(覆盖)|
|
||||
| `assets/logo/mipmap-xxxhdpi/ic_launcher.png` | `app/src/main/res/mipmap-xxxhdpi/ic_launcher.png`(覆盖)|
|
||||
| `assets/logo/mipmap-xxxhdpi/ic_launcher_round.png` | `app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png`(覆盖)|
|
||||
|
||||
### 1.2 Adaptive icon XML
|
||||
|
||||
把 `assets/android_resources/mipmap-anydpi-v26/` 下的两个 XML 也拷到对应目录:
|
||||
|
||||
| 源文件 | 目标 |
|
||||
|---|---|
|
||||
| `ic_launcher.xml` | `app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml` |
|
||||
| `ic_launcher_round.xml` | `app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml` |
|
||||
|
||||
> Android 8.0+ 会优先用 `<adaptive-icon>` XML。如果项目里已经有同名 XML,**整体覆盖**即可。
|
||||
|
||||
### 1.3 启动屏资源
|
||||
|
||||
| 源文件 | 目标 |
|
||||
|---|---|
|
||||
| `splash/drawable-mdpi/ic_splash_logo.png` | `app/src/main/res/drawable-mdpi/ic_splash_logo.png` |
|
||||
| `splash/drawable-hdpi/ic_splash_logo.png` | `app/src/main/res/drawable-hdpi/ic_splash_logo.png` |
|
||||
| `splash/drawable-xhdpi/ic_splash_logo.png` | `app/src/main/res/drawable-xhdpi/ic_splash_logo.png` |
|
||||
| `splash/drawable-xxhdpi/ic_splash_logo.png` | `app/src/main/res/drawable-xxhdpi/ic_splash_logo.png` |
|
||||
| `splash/drawable-xxxhdpi/ic_splash_logo.png` | `app/src/main/res/drawable-xxxhdpi/ic_splash_logo.png` |
|
||||
|
||||
### 1.4 XML 主题 / 颜色 / 字符串
|
||||
|
||||
| 源文件 | 目标 |
|
||||
|---|---|
|
||||
| `android_resources/values/colors_splash.xml` | `app/src/main/res/values/colors_splash.xml` |
|
||||
| `android_resources/values/strings_splash.xml` | `app/src/main/res/values/strings_splash.xml` |
|
||||
| `android_resources/values/themes_splash.xml` | `app/src/main/res/values/themes_splash.xml` |
|
||||
| `android_resources/values/dimens_splash.xml` | `app/src/main/res/values/dimens_splash.xml` |
|
||||
| `android_resources/drawable/splash_screen.xml` | `app/src/main/res/drawable/splash_screen.xml` |
|
||||
| `android_resources/layout/activity_splash.xml` | `app/src/main/res/layout/activity_splash.xml` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 集成步骤
|
||||
|
||||
### 2.1 加 SplashScreen 依赖
|
||||
|
||||
`gradle/libs.versions.toml`:
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
core-splashscreen = "1.0.1"
|
||||
|
||||
[libraries]
|
||||
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "core-splashscreen" }
|
||||
```
|
||||
|
||||
`app/build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
// ...
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 `AndroidManifest.xml` 改启动主题
|
||||
|
||||
```xml
|
||||
<application
|
||||
android:name=".DiaryNewsApp"
|
||||
android:theme="@style/Theme.App.Starting"
|
||||
...>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:theme="@style/Theme.App.Starting.Legacy" <!-- Android 11 及以下 -->
|
||||
android:exported="true">
|
||||
...
|
||||
</activity>
|
||||
</application>
|
||||
```
|
||||
|
||||
或者用 v31 区分:
|
||||
|
||||
```xml
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.App.Starting.Legacy" /> <!-- 用一个 launcher alias 兼容低版本 -->
|
||||
```
|
||||
|
||||
**最简单做法**:在 `MainActivity` 里 **不** 用 `android:theme`,改在 `onCreate` 里用 SplashScreen API 处理。
|
||||
|
||||
### 2.3 `MainActivity.kt`(SplashScreen API 用法)
|
||||
|
||||
```kotlin
|
||||
package com.diary.news
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.diary.news.ui.theme.DiaryNewsTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// 1. 安装 SplashScreen(必须在 super.onCreate 之前)
|
||||
val splashScreen = installSplashScreen()
|
||||
// 可选:保持启动屏直到数据加载完成
|
||||
// splashScreen.setKeepOnScreenCondition { !viewModel.isReady.value }
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
DiaryNewsTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
AppNav()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 应用名(`strings.xml`)
|
||||
|
||||
确保 `app/src/main/res/values/strings.xml` 有:
|
||||
|
||||
```xml
|
||||
<resources>
|
||||
<string name="app_name">Diary News</string>
|
||||
</resources>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 验证清单
|
||||
|
||||
装到真机后,逐项过:
|
||||
|
||||
- [ ] **桌面图标**:长按图标 → 看 launcher 上的视觉,是不是米色木质方块 D
|
||||
- [ ] **不同 launcher 风格**:长按图标 → 改 "Edit" / "Themes",确认 adaptive icon 正常(Pixel Launcher / Nova / 系统自带都试一下)
|
||||
- [ ] **启动屏**:点图标,启动时先看到木色启动屏 + Diary News 文字,然后进登录页 / Feed
|
||||
- [ ] **启动屏过渡**:启动屏消失时是否平滑(无白闪 / 黑闪)
|
||||
- [ ] **暗色模式**:系统切深色 → 启动屏颜色是否仍然温暖(用了固定木色,深色模式不变)
|
||||
- [ ] **横竖屏切换**:旋转屏幕时启动屏不崩(虽然启动时不会切,但要看 activity 不因配置变更崩)
|
||||
- [ ] **低版本兼容**:Android 7.0 / 8.0 真机测试启动屏是否正常显示
|
||||
- [ ] **多 DPI**:在 Pixel 2 (xhdpi) / Pixel 7 Pro (xxxhdpi) 上看图标清晰度
|
||||
|
||||
---
|
||||
|
||||
## 4. 关于"以后用域名访问"
|
||||
|
||||
Android 这边改 IP 为域名,只需要两处:
|
||||
|
||||
1. `app/build.gradle.kts` 里 `buildConfigField` 的 `API_BASE_URL` 改成 `https://你的域名/api/v1/`
|
||||
2. `app/src/main/res/xml/network_security_config.xml` 删掉 IP 白名单,改成默认 HTTPS 即可(或者保留 +加域名白名单)
|
||||
|
||||
代码不动,业务逻辑零变更。
|
||||
|
||||
---
|
||||
|
||||
## 5. 设计说明
|
||||
|
||||
### 颜色
|
||||
|
||||
| 角色 | HEX | 来源 |
|
||||
|---|---|---|
|
||||
| 木色底 | `#F5E9D0` | 参考图取色 |
|
||||
| 中木色 | `#E8D4A8` | 渐变中间色 |
|
||||
| 暗木色 | `#C9A876` | 阴影 / 渐变底部 |
|
||||
| 字母深棕 | `#3E2A1E` | 字母主体 |
|
||||
| 木纹线 | `#A8825A` | 半透明纹理 |
|
||||
|
||||
整套配色都是低饱和度暖色,**跟"私人日报 / 日记"的氛围匹配**,和 web 端 `#2080f0` 的蓝色主调形成"冷暖对比",反而有"早晚看新闻"的感觉。
|
||||
|
||||
### 字体
|
||||
|
||||
- 当前使用 Arial(Pillow 默认 fallback)
|
||||
- 真机运行时,SplashScreen 的文字是 SVG/drawable 渲染的,字体以系统为准
|
||||
- Compose 里的 `Text()` 可以用 `FontFamily.Serif`(衬线感更接近参考图)
|
||||
|
||||
### 安全区(Adaptive Icon)
|
||||
|
||||
- Android adaptive icon 要求 foreground 主体放中心 **66%**(108x108 中的 72x72 中心区)
|
||||
- 我们的 `safe_zone=True` 参数自动留了 22% padding,符合规范
|
||||
- **不要**给 foreground PNG 加背景色 —— 必须是透明 PNG
|
||||
- **不要**让主体元素贴边
|
||||
|
||||
### 启动屏时长
|
||||
|
||||
- SplashScreen API 的默认时长 ≈ 200ms(很短,基本看不到)
|
||||
- 我们做了 `setKeepOnScreenCondition` 注释,如果以后想"等数据加载完再消失",取消注释就行
|
||||
- **不要**手动 sleep 假装启动屏,Google Play 政策明文禁止
|
||||
289
docs/android/assets/_make_logo.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""重做字母 D — 用更干净的 polygon path。"""
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageChops, ImageFilter
|
||||
from pathlib import Path
|
||||
|
||||
OUT = Path(r'D:\selftools\diary-news\docs\android\assets')
|
||||
LOGO = OUT / 'logo'
|
||||
SPLASH = OUT / 'splash'
|
||||
|
||||
WOOD_LIGHT = (245, 233, 208)
|
||||
WOOD_MID = (232, 212, 168)
|
||||
WOOD_DARK = (201, 168, 118)
|
||||
GRAIN_LINE = (168, 130, 90)
|
||||
LETTER_DARK = (62, 42, 30)
|
||||
LETTER_DARKER = (42, 27, 16)
|
||||
|
||||
|
||||
def lerp(a, b, t):
|
||||
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
|
||||
|
||||
|
||||
def make_wood_gradient(w, h, top, mid, bot):
|
||||
img = Image.new('RGB', (w, h), top)
|
||||
px = img.load()
|
||||
for y in range(h):
|
||||
t = y / max(1, h - 1)
|
||||
c = lerp(top, mid, min(1.0, t * 2)) if t < 0.5 else lerp(mid, bot, (t - 0.5) * 2)
|
||||
for x in range(w):
|
||||
px[x, y] = c
|
||||
return img
|
||||
|
||||
|
||||
def add_wood_grain(img, spacing=10, opacity=40):
|
||||
w, h = img.size
|
||||
overlay = Image.new('RGBA', (w, h), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
for y in range(0, h, spacing):
|
||||
op = max(15, opacity - (y % spacing) * 2)
|
||||
draw.line([(0, y), (w, y)], fill=(*GRAIN_LINE, op), width=1)
|
||||
return Image.alpha_composite(img.convert('RGBA'), overlay)
|
||||
|
||||
|
||||
def draw_letter_D_simple(canvas_size, box):
|
||||
"""画一个干净的 D — 用 polygon 直接画出 D 的外形。
|
||||
D 的几何:左竖条 + 上下凸出半圆(右半)+ 中间挖空。
|
||||
"""
|
||||
img = Image.new('RGBA', canvas_size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
x0, y0, x1, y1 = box
|
||||
bw, bh = x1 - x0, y1 - y0
|
||||
cx = (x0 + x1) // 2
|
||||
cy = (y0 + y1) // 2
|
||||
|
||||
# D 的外轮廓尺寸
|
||||
dw = bw * 0.52
|
||||
dh = bh * 0.68
|
||||
L = int(cx - dw / 2) # 左
|
||||
R = int(cx + dw / 2) # 右
|
||||
T = int(cy - dh / 2) # 顶
|
||||
B = int(cy + dh / 2) # 底
|
||||
# 笔画粗细
|
||||
bar_w = dw * 0.32
|
||||
# 凸出(让 D 顶/底圆润)
|
||||
bulge = bar_w * 0.35
|
||||
|
||||
# 1) 画 D 的整体外形:左竖条 + 上下半圆 + 右半椭圆
|
||||
# 用 polygon 拼一个 D
|
||||
D_path = [
|
||||
# 左竖条顶
|
||||
(L, T + bulge),
|
||||
# 左竖条底
|
||||
(L, B - bulge),
|
||||
# 底弧起点
|
||||
(L + bar_w * 0.4, B),
|
||||
# 右半圆底部
|
||||
(R - bar_w * 0.3, B),
|
||||
# 右半圆弧最高点(顶部)
|
||||
(R, cy),
|
||||
# 右半圆底部 → 已经在 (R - bar_w*0.3, B)
|
||||
# 顶弧起点
|
||||
(L + bar_w * 0.4, T),
|
||||
]
|
||||
# 实际上我们直接用 pieslice 拼
|
||||
# 顶半圆:从 (L, T+bulge) 到 (R-bar_w*0.3, T),画 pieslice 180°~360°
|
||||
# 实际更简单:画三个形状叠起来
|
||||
|
||||
# a) 左竖条(矩形)
|
||||
draw.rectangle(
|
||||
[L, T + bulge * 0.5, L + bar_w, B - bulge * 0.5],
|
||||
fill=LETTER_DARK,
|
||||
)
|
||||
|
||||
# b) 顶半圆(右半,从 180° 到 360°,中心点)
|
||||
# pieslice 接受 bbox + start/end angle(角度,3 点钟方向=0,逆时针为正)
|
||||
# Pillow 中 pieslice 是顺时针 0=3 点,90=6 点,180=9 点,270=12 点
|
||||
# 我们要画右上 1/4 圆:从 270° 到 360°(即 12 点 → 3 点)不对
|
||||
# 重新想:画 D 的右半外轮廓,是一个完整的椭圆右半
|
||||
# 顶弧:从 (L+bar_w, T) 弧形向右下到 (R, cy)
|
||||
# 用 arc 描边粗一些,然后用 chord 实心填充
|
||||
|
||||
# 直接用 pieslice 实心填充 + ellipse 配合
|
||||
# 简化方案:
|
||||
# - 画一个完整 ellipse fill DARK
|
||||
# - 再画一个稍小的 ellipse fill 木色(挖空内部)
|
||||
# - 用矩形覆盖椭圆左半,挖出左边的"竖条"
|
||||
# 这样视觉上就是 D
|
||||
|
||||
# 整个 D 占的区域
|
||||
full_ell = [L, T, R + int(bulge * 0.5), B]
|
||||
# 让椭圆稍微超出矩形一点,确保右半圆足够圆
|
||||
inner_ell = [L + bar_w, T + bar_w * 1.05, R + int(bulge * 0.5) - bar_w * 0.55, B - bar_w * 1.05]
|
||||
|
||||
# 1) 整个外轮廓 fill DARK
|
||||
draw.ellipse(full_ell, fill=LETTER_DARK)
|
||||
# 2) 内部挖空(fill 木色,让字母透出底)
|
||||
draw.ellipse(inner_ell, fill=WOOD_LIGHT)
|
||||
# 3) 用矩形盖住椭圆左半,形成 D 的竖条
|
||||
# 矩形左边到 L+bar_w*0.9,右边到 inner_ell 的左侧+一点
|
||||
rect_left = L
|
||||
rect_right = L + bar_w + (inner_ell[0] - (L + bar_w)) // 2 + 2
|
||||
# 让矩形比椭圆略矮,保持椭圆上下凸出
|
||||
rect_top = T + bar_w * 0.85
|
||||
rect_bot = B - bar_w * 0.85
|
||||
# 矩形 fill DARK(竖条)
|
||||
draw.rectangle([rect_left, rect_top, rect_right, rect_bot], fill=LETTER_DARK)
|
||||
# 4) 在矩形右侧挖一个米色矩形,让 D 中间真的空出来
|
||||
# 计算 D 中间的"肚子"位置
|
||||
mid_left = L + bar_w + 4
|
||||
mid_right = R - bar_w * 0.4
|
||||
mid_top = T + bar_w * 1.1
|
||||
mid_bot = B - bar_w * 1.1
|
||||
# 用椭圆 fill 米色 覆盖中间的"空腔"
|
||||
# 但这样会把竖条也覆盖,改成用 polygon
|
||||
# 实际上 inner_ell 已经挖空了椭圆内部,现在要把"竖条"也挖掉中间一部分
|
||||
# 方法:用 ellipse 在竖条右侧挖一个椭圆洞
|
||||
draw.ellipse(
|
||||
[mid_left, mid_top, mid_right, mid_bot],
|
||||
fill=WOOD_LIGHT,
|
||||
)
|
||||
# 5) 用矩形 cover 椭圆左半(只留右边空腔)
|
||||
cover_left = mid_left - 5
|
||||
cover_right = mid_left + (mid_right - mid_left) * 0.30
|
||||
draw.rectangle(
|
||||
[cover_left, mid_top + (mid_bot - mid_top) * 0.05,
|
||||
cover_right, mid_bot - (mid_bot - mid_top) * 0.05],
|
||||
fill=WOOD_LIGHT,
|
||||
)
|
||||
|
||||
# 6) 描深色边
|
||||
# 左竖条外缘
|
||||
draw.line([(L, T + bulge * 0.5), (L, B - bulge * 0.5)], fill=LETTER_DARKER, width=3)
|
||||
# 顶弧右端 + 右半圆 + 底弧右端
|
||||
draw.arc(full_ell, start=270, end=90, fill=LETTER_DARKER, width=3)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def make_block_icon(size, safe_zone=False):
|
||||
canvas_size = (size, size)
|
||||
if safe_zone:
|
||||
pad = int(size * 0.22)
|
||||
else:
|
||||
pad = int(size * 0.08)
|
||||
box = (pad, pad, size - pad, size - pad)
|
||||
|
||||
bw, bh = box[2] - box[0], box[3] - box[1]
|
||||
wood = make_wood_gradient(bw, bh, WOOD_LIGHT, WOOD_MID, WOOD_DARK)
|
||||
wood = add_wood_grain(wood, spacing=int(size * 0.025), opacity=50)
|
||||
|
||||
wood_rgba = Image.new('RGBA', canvas_size, (0, 0, 0, 0))
|
||||
mask = Image.new('L', (bw, bh), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle([0, 0, bw - 1, bh - 1], radius=int(size * 0.18), fill=255)
|
||||
wood_rgba.paste(wood, (box[0], box[1]), mask)
|
||||
|
||||
edge = Image.new('RGBA', canvas_size, (0, 0, 0, 0))
|
||||
ed = ImageDraw.Draw(edge)
|
||||
ed.rounded_rectangle([box[0], box[1], box[2] - 1, box[3] - 1],
|
||||
radius=int(size * 0.18), outline=(107, 79, 48, 200), width=2)
|
||||
wood_rgba = Image.alpha_composite(wood_rgba, edge)
|
||||
|
||||
shadow = Image.new('RGBA', canvas_size, (0, 0, 0, 0))
|
||||
sd = ImageDraw.Draw(shadow)
|
||||
sd.ellipse([int(size * 0.28), int(size * 0.93), int(size * 0.72), int(size * 1.02)], fill=(0, 0, 0, 50))
|
||||
shadow = shadow.filter(ImageFilter.GaussianBlur(4))
|
||||
|
||||
hl = Image.new('RGBA', canvas_size, (0, 0, 0, 0))
|
||||
hd = ImageDraw.Draw(hl)
|
||||
hd.rounded_rectangle(
|
||||
[box[0], box[1], box[2] - 1, box[1] + bh // 2],
|
||||
radius=int(size * 0.18), fill=(255, 255, 255, 35),
|
||||
)
|
||||
|
||||
letter = draw_letter_D_simple(canvas_size, box)
|
||||
|
||||
final = Image.new('RGBA', canvas_size, (0, 0, 0, 0))
|
||||
final = Image.alpha_composite(final, shadow)
|
||||
final = Image.alpha_composite(final, wood_rgba)
|
||||
final = Image.alpha_composite(final, hl)
|
||||
final = Image.alpha_composite(final, letter)
|
||||
return final
|
||||
|
||||
|
||||
def make_round_icon(size):
|
||||
canvas = Image.new('RGBA', (size, size), (0, 0, 0, 0))
|
||||
wood = make_wood_gradient(size, size, WOOD_LIGHT, WOOD_MID, WOOD_DARK)
|
||||
wood = add_wood_grain(wood, spacing=int(size * 0.04), opacity=50)
|
||||
|
||||
mask = Image.new('L', (size, size), 0)
|
||||
ImageDraw.Draw(mask).ellipse([0, 0, size - 1, size - 1], fill=255)
|
||||
canvas.paste(wood, (0, 0), mask)
|
||||
|
||||
letter = draw_letter_D_simple(
|
||||
(size, size),
|
||||
(int(size * 0.20), int(size * 0.20), int(size * 0.80), int(size * 0.80)),
|
||||
)
|
||||
canvas = Image.alpha_composite(canvas, letter)
|
||||
|
||||
hl = Image.new('RGBA', (size, size), (0, 0, 0, 0))
|
||||
hd = ImageDraw.Draw(hl)
|
||||
hd.ellipse([int(size * 0.1), int(size * 0.05), int(size * 0.9), int(size * 0.55)],
|
||||
fill=(255, 255, 255, 35))
|
||||
hl_mask = Image.new('L', (size, size), 0)
|
||||
ImageDraw.Draw(hl_mask).ellipse([0, 0, size - 1, size - 1], fill=255)
|
||||
hl.putalpha(ImageChops.multiply(hl.split()[3], hl_mask))
|
||||
canvas = Image.alpha_composite(canvas, hl)
|
||||
return canvas
|
||||
|
||||
|
||||
# === 重新生成 ===
|
||||
print('[1] master 1024')
|
||||
make_block_icon(1024, safe_zone=False).save(LOGO / 'icon_master_1024.png')
|
||||
|
||||
print('[2] adaptive icon 432')
|
||||
make_block_icon(432, safe_zone=True).save(LOGO / 'ic_launcher_foreground.png')
|
||||
bg = make_wood_gradient(432, 432, WOOD_LIGHT, WOOD_MID, WOOD_DARK)
|
||||
bg = add_wood_grain(bg, spacing=12, opacity=30)
|
||||
bg.save(LOGO / 'ic_launcher_background.png')
|
||||
|
||||
print('[3] launcher icons 5 DPI')
|
||||
SIZES = {'mdpi': 48, 'hdpi': 72, 'xhdpi': 96, 'xxhdpi': 144, 'xxxhdpi': 192}
|
||||
icon_master = make_block_icon(1024, safe_zone=False)
|
||||
for dpi, sz in SIZES.items():
|
||||
d = LOGO / f'mipmap-{dpi}'
|
||||
d.mkdir(exist_ok=True)
|
||||
icon_master.resize((sz, sz), Image.LANCZOS).save(d / 'ic_launcher.png')
|
||||
make_round_icon(sz).save(d / 'ic_launcher_round.png')
|
||||
print(f' {dpi}: {sz}x{sz}')
|
||||
|
||||
print('[4] 启动屏 logo 512')
|
||||
make_block_icon(512, safe_zone=False).save(SPLASH / 'splash_logo.png')
|
||||
|
||||
print('[5] 启动屏背景 1080x1920')
|
||||
bg_w, bg_h = 1080, 1920
|
||||
bg_full = make_wood_gradient(bg_w, bg_h, WOOD_LIGHT, WOOD_MID, WOOD_DARK)
|
||||
bg_full = add_wood_grain(bg_full, spacing=20, opacity=35).convert('RGBA')
|
||||
logo_big = make_block_icon(512, safe_zone=False)
|
||||
logo_x = (bg_w - 512) // 2
|
||||
logo_y = (bg_h - 512) // 2 - 100
|
||||
bg_full.alpha_composite(logo_big, (logo_x, logo_y))
|
||||
|
||||
draw = ImageDraw.Draw(bg_full)
|
||||
try:
|
||||
font_big = ImageFont.truetype('arial.ttf', 110)
|
||||
font_sm = ImageFont.truetype('arial.ttf', 38)
|
||||
except Exception:
|
||||
font_big = ImageFont.load_default()
|
||||
font_sm = ImageFont.load_default()
|
||||
|
||||
name = 'Diary News'
|
||||
bb = draw.textbbox((0, 0), name, font=font_big)
|
||||
tw = bb[2] - bb[0]
|
||||
draw.text(((bg_w - tw) // 2, logo_y + 512 + 80), name, fill=LETTER_DARK, font=font_big)
|
||||
|
||||
sub = 'Your Private News Diary'
|
||||
bb2 = draw.textbbox((0, 0), sub, font=font_sm)
|
||||
tw2 = bb2[2] - bb2[0]
|
||||
draw.text(((bg_w - tw2) // 2, logo_y + 512 + 230), sub, fill=(90, 65, 40), font=font_sm)
|
||||
|
||||
bg_full.save(SPLASH / 'splash_bg_full.png')
|
||||
print(' OK')
|
||||
|
||||
print('[6] 启动屏各 DPI logo')
|
||||
SPLASH_SIZES = {'mdpi': 192, 'hdpi': 288, 'xhdpi': 384, 'xxhdpi': 576, 'xxxhdpi': 768}
|
||||
for dpi, sz in SPLASH_SIZES.items():
|
||||
d = SPLASH / f'drawable-{dpi}'
|
||||
d.mkdir(exist_ok=True, parents=True)
|
||||
make_block_icon(sz, safe_zone=False).save(d / 'ic_splash_logo.png')
|
||||
print(f' {dpi}: {sz}x{sz}')
|
||||
|
||||
print('\n=== 完成 ===')
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
启动屏背景 drawable(用于 Android 11 及以下的 windowBackground)
|
||||
- 米色木纹底
|
||||
- 中央 logo + app 名
|
||||
-->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- 底层:木色背景 -->
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:angle="90"
|
||||
android:startColor="#F5E9D0"
|
||||
android:centerColor="#E8D4A8"
|
||||
android:endColor="#C9A876" />
|
||||
</shape>
|
||||
</item>
|
||||
|
||||
<!-- 中央 logo 图标(用 bitmap 引用,需要把 splash_logo.png 放进来)-->
|
||||
<!-- 实际项目中,把生成好的 splash_logo.png 放到 drawable-xxhdpi/ 等目录 -->
|
||||
<item android:gravity="center" android:top="-40dp">
|
||||
<bitmap
|
||||
android:src="@drawable/ic_splash_logo"
|
||||
android:gravity="center" />
|
||||
</item>
|
||||
|
||||
</layer-list>
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
启动屏 layout(SplashScreen API 12+ 用)
|
||||
- 背景 = 木色
|
||||
- 中心 = logo + app 名 + 副标题
|
||||
- 不依赖任何外部依赖,纯原生
|
||||
-->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:background="@color/splash_background">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/splash_logo"
|
||||
android:layout_width="@dimen/splash_logo_size"
|
||||
android:layout_height="@dimen/splash_logo_size"
|
||||
android:src="@drawable/ic_splash_logo"
|
||||
android:contentDescription="@string/app_name"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/splash_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_name"
|
||||
android:textColor="@color/splash_letter"
|
||||
android:textSize="40sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="sans-serif" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/splash_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/splash_subtitle"
|
||||
android:textColor="@color/splash_subtitle"
|
||||
android:textSize="16sp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:fontFamily="sans-serif" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 木质积木色调,跟 logo 一致 -->
|
||||
<color name="splash_background">#F5E9D0</color>
|
||||
<color name="splash_letter">#3E2A1E</color>
|
||||
<color name="splash_subtitle">#5A4128</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 启动屏 logo 尺寸(根据屏宽自适应,这里用 dp)-->
|
||||
<dimen name="splash_logo_size">160dp</dimen>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 启动屏要用的副标题(中英两版都备一份)-->
|
||||
<string name="splash_subtitle">Your Private News Diary</string>
|
||||
<string name="splash_subtitle_zh">你的私人新闻日报</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
SplashScreen API(Android 12+, API 31+)主题
|
||||
用 Theme.SplashScreen 库,需要在 build.gradle 依赖 androidx.core:core-splashscreen
|
||||
-->
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Android 12+ 启动屏主题(API 31+)-->
|
||||
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/splash_background</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item>
|
||||
<item name="windowSplashScreenAnimationDuration">200</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.DiaryNews</item>
|
||||
</style>
|
||||
|
||||
<!-- Android 11 及以下启动屏主题(API < 31)— 用自定义 drawable 作 background-->
|
||||
<style name="Theme.App.Starting.Legacy" parent="Theme.DiaryNews">
|
||||
<item name="android:windowBackground">@drawable/splash_screen</item>
|
||||
<item name="android:statusBarColor">@color/splash_background</item>
|
||||
<item name="android:navigationBarColor">@color/splash_background</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
99
docs/android/assets/logo.svg
Normal file
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Diary News — App Logo
|
||||
风格:木质方块字母积木(参考用户提供的图)
|
||||
设计:单个大方块 + 字母 "D",代表 "Diary"
|
||||
颜色:米色底 (#F5E9D0) + 深棕字母 (#3E2A1E) + 浅色高光
|
||||
用途:Android adaptive icon foreground (108x108dp, 安全区 66dp)
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 108 108"
|
||||
width="108" height="108">
|
||||
|
||||
<defs>
|
||||
<!-- 木纹渐变(米色基调,顶部亮、底部暗)-->
|
||||
<linearGradient id="woodBase" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#F5E9D0"/>
|
||||
<stop offset="50%" stop-color="#E8D4A8"/>
|
||||
<stop offset="100%" stop-color="#C9A876"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- 木纹纹理(横向纹理线)-->
|
||||
<pattern id="grain" patternUnits="userSpaceOnUse" width="108" height="3">
|
||||
<rect width="108" height="3" fill="url(#woodBase)"/>
|
||||
<line x1="0" y1="1.5" x2="108" y2="1.5" stroke="#A8825A" stroke-width="0.3" opacity="0.3"/>
|
||||
</pattern>
|
||||
|
||||
<!-- 字母 D 的木色(比底色更深的实木色)-->
|
||||
<linearGradient id="letterWood" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#5C3D24"/>
|
||||
<stop offset="100%" stop-color="#3E2A1E"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- 顶面(轻微高光)-->
|
||||
<linearGradient id="topHighlight" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.4"/>
|
||||
<stop offset="100%" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- 侧面(更暗,营造立体感)-->
|
||||
<linearGradient id="sideShadow" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#8B6B45"/>
|
||||
<stop offset="100%" stop-color="#6B4F30"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- 圆角遮罩 -->
|
||||
<clipPath id="blockShape">
|
||||
<rect x="14" y="14" width="80" height="80" rx="14" ry="14"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<!-- 整组外发光阴影(模拟方块落在桌面)-->
|
||||
<ellipse cx="54" cy="92" rx="32" ry="3" fill="#000000" opacity="0.15"/>
|
||||
|
||||
<!-- 方块主体(底面/侧面/正面三层)-->
|
||||
<!-- 底面(从左下到右下的厚度)-->
|
||||
<path d="M 14 88 L 14 94 Q 14 98 18 98 L 90 98 Q 94 98 94 94 L 94 88 Z"
|
||||
fill="url(#sideShadow)" opacity="0.6"/>
|
||||
|
||||
<!-- 正面 -->
|
||||
<rect x="14" y="14" width="80" height="80" rx="14" ry="14"
|
||||
fill="url(#grain)"/>
|
||||
|
||||
<!-- 木纹细节:几条横向纹路 -->
|
||||
<g clip-path="url(#blockShape)" opacity="0.4">
|
||||
<line x1="14" y1="28" x2="94" y2="28" stroke="#A8825A" stroke-width="0.4"/>
|
||||
<line x1="14" y1="38" x2="94" y2="38" stroke="#A8825A" stroke-width="0.3"/>
|
||||
<line x1="14" y1="54" x2="94" y2="54" stroke="#A8825A" stroke-width="0.5"/>
|
||||
<line x1="14" y1="68" x2="94" y2="68" stroke="#A8825A" stroke-width="0.3"/>
|
||||
<line x1="14" y1="82" x2="94" y2="82" stroke="#A8825A" stroke-width="0.4"/>
|
||||
</g>
|
||||
|
||||
<!-- 圆角描边(加深轮廓)-->
|
||||
<rect x="14" y="14" width="80" height="80" rx="14" ry="14"
|
||||
fill="none" stroke="#6B4F30" stroke-width="1.5" opacity="0.5"/>
|
||||
|
||||
<!-- 顶面高光(轻微内阴影 + 顶部反光)-->
|
||||
<rect x="14" y="14" width="80" height="40" rx="14" ry="14"
|
||||
fill="url(#topHighlight)" opacity="0.6"/>
|
||||
|
||||
<!-- 字母 "D" — 用粗体衬线感字体 -->
|
||||
<text x="54" y="74"
|
||||
font-family="Georgia, 'Times New Roman', serif"
|
||||
font-size="58"
|
||||
font-weight="900"
|
||||
text-anchor="middle"
|
||||
fill="url(#letterWood)"
|
||||
stroke="#2A1B10"
|
||||
stroke-width="0.8">D</text>
|
||||
|
||||
<!-- 字母上的高光(让字母也有木纹感)-->
|
||||
<text x="54" y="74"
|
||||
font-family="Georgia, 'Times New Roman', serif"
|
||||
font-size="58"
|
||||
font-weight="900"
|
||||
text-anchor="middle"
|
||||
fill="#FFFFFF"
|
||||
opacity="0.08">D</text>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
BIN
docs/android/assets/logo/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
docs/android/assets/logo/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
docs/android/assets/logo/icon_foreground_1024.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/android/assets/logo/icon_master_1024.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
docs/android/assets/logo/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
docs/android/assets/logo/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
docs/android/assets/logo/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
docs/android/assets/logo/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 966 B |
BIN
docs/android/assets/logo/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
docs/android/assets/logo/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
docs/android/assets/logo/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
docs/android/assets/logo/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
docs/android/assets/logo/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
docs/android/assets/logo/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
docs/android/assets/splash/drawable-hdpi/ic_splash_logo.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
docs/android/assets/splash/drawable-mdpi/ic_splash_logo.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
docs/android/assets/splash/drawable-xhdpi/ic_splash_logo.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
docs/android/assets/splash/drawable-xxhdpi/ic_splash_logo.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
docs/android/assets/splash/drawable-xxxhdpi/ic_splash_logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
docs/android/assets/splash/splash_bg_full.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/android/assets/splash/splash_logo.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |