hanphone-blog

云林有风 Android 客户端 — AI 交接文档(HANDOVER)

用途:供新接手 AI 快速理解项目、规避前人踩过的坑。 位置:android/。配套文件:README.md(构建)、本文件(经验与规矩)。 铁律:改动前先读 web/(网站前端)与 docs/chat/(聊天协议,原 hanphone-chat 已并入本仓库)作为接口与交互的”唯一事实来源”,不要凭直觉假设。


1. 项目定位

博客「云林有风」的 Android 原生客户端。Kotlin + Jetpack Compose (Material 3),对接博客 REST API 与 hanphone-chat 实时聊天。后端代码零改动。

2. 代码地图(app/src/main/java/com/hanphone/blog/)

MainActivity.kt         入口(enableEdgeToEdge + BlogAppRoot)
BlogApplication.kt      Application:初始化 ContentStore / ChatNotifier
core/Settings.kt        DataStore:主题模式/自定义背景/访客身份/登录态(AuthData)
data/
  AppGraph.kt           ServiceLocator:进程级 Repository 单例(VM 层使用)
  auth/TokenStore.kt    进程内登录态:token/userId/nickname/avatar/userType
  api/ApiClient.kt      Retrofit×2:api(hanphone.cn/api, 注入 Token 头) + fileApi(hanphone.top)
  api/BlogApi.kt        全部 REST(含聊天 REST 绝对路径 + Bearer + limit 参数)
  api/FileApi.kt        上传 /upload/avatar
  cache/MemoryCache.kt  内存缓存:返回秒显(进程存活期)
  cache/ContentStore.kt 磁盘缓存:冷启动秒显(filesDir JSON,Gson 读写)
  chat/ChatSocket.kt    socket.io 单例:聊天室/私信/AI 流式/心跳(25s)/chatUiVisible
  chat/ChatNotifier.kt  socket 私信系统通知(委托 PushNotifications)
  chat/ChatModels.kt    聊天相关模型
  push/PushNotifications.kt    通知渠道(chat_messages / hot_updates)+ 通知展示
  push/PushRegistrar.kt        FCM token 取用/上报/注销(google-services.json 缺失则静默跳过)
  push/BlogMessagingService.kt FCM 前台消息展示 + onNewToken 上报
  repo/Repos.kt         BlogRepository / EssayRepository / MessageRepository / FileRepository
ui/
  App.kt                根:主题+背景层+底部导航+路由(4 Tab,200ms 淡入淡出过渡);登录态建立即常连 ChatSocket(离开消息页也收私信通知)+ 请求通知权限 + 「消息」Tab 未读红点
  Background.kt         自定义背景层(全局)
  home/HomeScreen.kt + HomeViewModel.kt        首页(列表/筛选/归档全在 VM;分类并入筛选面板)
  essay/EssaysScreen.kt + EssaysViewModel.kt   随笔列表 VM + 详情 VM
  chat/ChatScreen.kt    消息 Hub(状态在 ChatSocket 单例,勿重复建 VM)
  detail/ArticleDetailScreen.kt + ArticleDetailViewModel.kt(key 按博客 id)
  message/MessageScreen.kt + MessageBoardViewModel.kt
  friendlinks/FriendLinksViewModel.kt  search/SearchViewModel.kt
  projects/ProjectsScreen.kt + ProjectsViewModel.kt  项目页(/projects,筛选分组卡片)
  docs/DocsScreen.kt + DocsViewModel.kt         文库页(docNamespace 文件夹树/搜索/类型筛选)
  hot/HotScreen.kt + HotViewModel.kt             热点聚合(对标 web /insight:热点流/模型榜单/综合对比;对比支持分享链接)
  docs/DocMarkdownScreen.kt                     文库 MD 预览(拉文件服务原文 → Markdown 渲染)
  webview/WebViewScreen.kt      通用 WebView(项目链接 loadUrl / 文库 HTML 文档 fetch+loadDataWithBaseURL;
                               进度条/返回/浏览器外开;主框架加载失败显示「重试/在浏览器打开」错误层)
  core/AuthBridge.kt            第一方 WebView 页(照片墙 hanphone.cn/atlas)登录态注入 + 登出清理
  profile/ProfileScreen.kt + ProfileViewModel.kt
  components/           ArticleCard/Avatar/Skeleton(骨架屏)/Loading/Error/Empty/BottomActionItem
                      SearchField(40dp 紧凑搜索胶囊,替代 M3 OutlinedTextField 56dp)/详情底部栏=细分割线+单行图标文字
  theme/                Material3 Light/Dark 双主题

ViewModel 约定(新页面照此写)

3. 后端接口与「坑」(务必牢记)

3.1 通用

3.2 认证 — 最大的坑

3.3 评论 / 点赞

3.4 上传(admin-file)

3.5 聊天(hanphone-chat)— 协议

3.6 留言板(对齐网页版:不填昵称、支持回复)

4. 信息架构与每屏要点(含用户明确要求)

首页

随笔 = 朋友圈(去卡片化)

消息 = Hub

我的 / 设置

登录 / 注册

5. 主题与视觉规范(用户明确偏好,勿擅自改)

6. 开发环境(本机 Linux)

# 构建(必须带代理 GRADLE_OPTS:Gradle 发行版走 github 需代理,依赖仓库直连)
export GRADLE_OPTS="-Dhttp.proxyHost=127.0.0.1 -Dhttp.proxyPort=10808 -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=10808"
./gradlew :app:assembleDebug --no-daemon

# 装机 + 验证
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n com.hanphone.blog/.MainActivity
adb shell uiautomator dump /sdcard/ui.xml && adb shell cat /sdcard/ui.xml   # 看渲染文本
adb logcat -d | grep -i "FATAL EXCEPTION"                                   # 崩溃检查(必须 0)

7. 踩坑记录(前车之鉴)

  1. 登录失败:一直发明文密码 → 应为 md5(明文)。(最高频)
  2. “无法登录”:旧 token 卡登录入口 → 401 自动登出修复。
  3. 私信崩溃:retrofit2.HttpException: HTTP 403 —— 管理员账号调普通用户接口;网络请求必须 try/catch。
  4. 聊天“连接失败”/收不到通知:连接钩子在 ui/App.kt 的 BlogAppRoot(登录态建立即常连)——重构时易被删掉;删了就会退回到「必须先打开消息页才连、后台收不到私信通知」。 4a. 整 App 卡加载:VM 化重构时丢掉了页面里的 LaunchedEffect(Unit){ load() } 首次加载调用——VM init 只读缓存不发请求,所有列表页永远停在骨架屏。规矩:每个 VM 的 init 必须自己发起首载,改完用 logcat 确认请求真的发出(adb logcat | grep okhttp)。
  5. 筛选面板误解:用户说“从上往下”= 从搜索栏向下弹出(overlay+遮罩),不是改内部为纵向列表。
  6. App 图标 vs PWA 图标(maskable 行为,重大):自适应图标前景是整张画布铺到桌面,圆遮罩只显示中央 ~61%。
    • 直接把 PWA 图标当前景(logo 68% 画布)→ 遮罩内 >100% 被裁切,看起来”过大/残”;
    • 按安全区缩小到 42% → 遮罩内 ~63%,但比 PWA 安装图标偏小(新手常见);
    • 与 PWA 一致的正确做法:装 PWA 时 Chrome 生成 WebAPK,用 maskable 图标全出血铺满前景 + 白背景,且 Chrome 预生成的前景里 logo 占 ~52% 画布、四周透明 → 遮罩内 ≈85%。
    • 复刻方法:adb pull WebAPK 的 res/mipmap-nodpi-v4/maskable_app_icon_xxxhdpi.png 量出 logo 占画布比例 → 用 ffmpeg「透明 1024 底 + 原图缩放到 logo=该比例」生成 ic_launcher_fg.png(背景层保持白)。验证:装后截图,ffmpeg 解码量「白面内 logo 占比」≈ 77-84% 即与 PWA 一致。
    • 一句话:别调大小碰运气——直接提取 WebAPK 图标抄比例。旧备注「白底+62% 安全区」作废。
  7. 底部导航遮挡/偏灰:别用默认 NavigationBar 高度 hack;用自定义 Surface+Row,手势区给底色。
  8. Kotlin/Compose 易错点:
    • 委托属性(by remember)不能直接 smart cast(用 !! 或局部变量)
    • Modifier.weight 只在 Row/Column/Box 内容作用域里可用
    • 扩展成员放进 object 无法被外部 import —— 写顶层扩展
    • KeyboardOptions 在 androidx.compose.foundation.text(不是 ui.text.input)
    • LocalView.current 只能在 @Composable 里取(先存局部变量再给普通函数用)
    • 大段重构容易弄丢花括号/回调/LaunchedEffect —— 改完先编译再装机
    • import 写错包名/重复 import 是高频编译错(改完自查 import 块)
  9. 验证闭环:每次改完 = 构建 SUCCESS → 装机 → uiautomator 看关键文本/位置 → 崩溃检查 = 0。
  10. 例行状态别当通知弹:聊天 socket 每次连接服务端都会发 notification:"认证成功…"(server.ts:519)——已在 ChatSocket 源头过滤(startsWith("认证成功") 不进 notice);且 StateFlow.collect 进页会重放当前值,收集通知要用 notice.drop(1),否则每次进消息页重弹上次通知。
  11. Coil 缓存管理(设置页数据管理):图片缓存不进 ContentStore(它在 App 缓存目录),数据管理单独统计/清除 LocalImageLoader.current 的 diskCache.size/clear() + memoryCache.size/clear()。坑:MemoryCache.size 是 Int(与 ?: 0L 合并会类型漂移成 Number 编译错,需 .toLong());DiskCache.clear() 是 suspend。
  12. 图片占位:未登录头像/登录注册页/关于徽标统一用 R.drawable.ic_blog(拷贝自 web icon-512x512.png)——别用文字「云」当占位。
  13. 列表图片一律走缩略图:随笔九宫格用 displayUrl(),详情页大图才用 originalUrl()。原图单张可达 12MB、单篇最多 9 张(约 40MB),Coil 会把磁盘缓存撑爆且拖垮 2C2G 的服务器。服务端(admin-file)上传时已生成 320w/800w/1600w WebP,前端只要带上 thumbPath 就够了。
  14. 照片墙是 WebView,不是原生页:https://hanphone.cn/atlas/ 由 nginx alias 托管的 Vue SPA,图片走浏览器内核加载 + Service Worker 缓存,不受 App「数据管理 → 图片缓存」控制。登录态通过 core/AuthBridge.kt 注入(见下条),不再各存一份。

  15. 第一方 WebView 页面必须注入 App 登录态(core/AuthBridge.kt):
    • 照片墙的登录态存在它自己的 localStorage(token / userInfo / expire),与 App 的 DataStore 是两套互不相通的存储。不注入就会出现「App 里退出登录、照片墙仍显示已登录还能点赞/进管理页」。
    • 用 WebViewCompat.addDocumentStartJavaScript,不是 onPageFinished + evaluateJavascript:照片墙的 Pinia store 在模块初始化时同步读 localStorage(apps/photo-wall/src/store/store.ts),晚一拍就来不及了。需 androidx.webkit(已加,libs.versions.toml 的 webkit = 1.12.1),WebView < 83 走 evaluateJavascript + reload() 降级。
    • 只对 AuthBridge.FIRST_PARTY_ORIGINS(hanphone.cn / www.hanphone.cn)生效。项目页会打开任意外链,加白名单前想清楚:进去的域名就等于拿到用户 token。
    • App 未登录时脚本是删除这三个键(不是跳过)——否则用户在照片墙里单独登录过、之后又在 App 退出,照片墙会一直带着旧 token。
    • 登出入口在 ProfileScreen 的「退出」:clearAuth() + TokenStore.clear() + AuthBridge.clearWebStorage()。用 WebStorage.deleteAllData() 而不是 evaluateJavascript("localStorage.clear()"):后者要求 WebView 实例存活,而登出时页面往往已销毁;WebStorage 也不会误删 Cache Storage(Service Worker 资源缓存)。
    • 历史坑(已修,别再踩):早期没有注入,但照片墙却显示「已登录」,看起来像注入已经存在。真相是照片墙自带的登录页在 WebView 里被用过一次,domStorageEnabled = true 让 localStorage 按 origin 持久留在 App 数据目录里。domStorageEnabled=true 意味着 WebView 里的状态会跨会话存活,排查「为什么它记得我」时先想到这条。
    • 注入的 userInfo 需要 id / type / username / nickname / avatar 五个字段。其中 username(登录名,非昵称)App 原本没存——为此给 AuthData 加了 username 字段并落到 DataStore,登录/注册/改资料三处都要传。漏了会导致照片墙管理页按登录名定位不到当前用户。
    • ⚠️ 别踩这个顺序坑:context.auth.collectAsState(initial = AuthData()) 的 initial 必须写成 null,不能用 AuthData() 兜底。否则首帧 currentAuth 是空的 → 注入脚本按「未登录」生成(删除三个键)→ 紧接着 loadUrl 加载 → 页面带着清除登录态的脚本加载完成,App 明明已登录但照片墙显示未登录,要进第二次才对。正确写法:用 null 表示「还没读到」,把注入和加载都 gate 在 authResolved 上,且注入的 LaunchedEffect 必须声明在加载的前面(Compose 按声明顺序启动,install() 又是同步调用)。同理不要在 AndroidView 的 factory 里注入——那里执行时 DataStore 大概率还没吐值。
  16. Android WebView 里 CSS 视口单位(vh/dvh/svh/lvh)会被算成 0(重大,真机实测):WebView 首次布局高度为 0,之后尺寸变化不会重算这些单位,但 window.innerHeight / visualViewport.height 是正确的(684)。后果:照片墙侧边栏 height: 100vh → 高度 0、完全不可见;三种视图容器 min-height: 100vh → 加载中(还没有照片)时背景层撑不开、下方露白。不要在 App 内的 WebView 页面依赖 vh。修复:apps/photo-wall/src/main.ts 用 window.innerHeight 写入 --app-vh(并在 resize/orientationchange/rAF 时重设),CSS 里 100vh 一律写成 var(--app-vh, 100vh)(普通浏览器没有这个 bug,走回退值)。排查「某个层高度不对/不可见」先量 getComputedStyle 与 getBoundingClientRect。

  17. Moshi 自定义 Date 适配器必须放行 JSON null(重大,热点聚合真机暴露):ApiClient.BlogDateAdapter.fromJson 原来只判 NUMBER,否则走 reader.nextString();遇到显式 null(热点聚合的 publishedAt / releaseDate / 总览 topItems 里的 hf 项)会在 NULL token 上抛 JsonDataException。由于所有接口都被 runCatching 包裹,异常被静默吞掉 → 表现为「整包数据凭空消失」(HF 趋势 0 条、总览信息行不渲染、text/agent 等模态榜单为空),既不崩溃也无日志,极难排查。已在适配器开头判 JsonReader.Token.NULL 返回 null。教训:自定义 Moshi 适配器要么自己 nullSafe(),要么显式处理 NULL;解析失败一定要留日志,别让 runCatching 无声吞掉。
    • 连带坑:缓存了「部分成功」的结果时,不能用 isEmpty() 判断已加载。上一版只解析成功部分模态并落盘 hot_leaderboards.json,重进页面 leaderboards 非空 → 跳过重拉,缺失模态永远空。已改为「缺哪个补哪个」(size < MODALITIES.size 就重拉,且新结果合并进旧 map,失败模态保留旧数据)。

8. 路线图

10. 关键技术决策(本次重构后)

  1. DI:Hilt(@HiltAndroidApp/@AndroidEntryPoint/@HiltViewModel);详情页 id 走 SavedStateHandle["blogId"/"essayId"],不再手写 factory/key。
  2. JSON:Moshi + KotlinJsonAdapterFactory(非空字段缺失直接抛错,替代 Gson 的静默塞 null);Date 兼容适配器在 ApiClient.BlogDateAdapter(毫秒数字/ISO/普通日期);模型注解用 @Json(name=...)(原 Gson @SerializedName 已迁移)。
  3. 分页:随笔列表用 Paging 3(EssayPagingSource,key=1 起始页码,paging-compose 的 collectAsLazyPagingItems + itemKey + loadState 驱动骨架/页脚/重试)。冷启动缓存:第一页成功即写 MemoryCache.essayMoments + ContentStore.writeEssayFirstPage,进入列表页时 Paging 首屏未到前先用缓存秒显(EssayListViewModel.coldFeed),数据到达后键一致无缝替换——不再进场闪骨架屏。 3a. 缓存层扩展(加载体验专项):ContentStore 新增 types/tags/archive、essay_comments_{id}、article_{id}(+comments)、essay{id}、doc_{hash}(文库 MD/HTML 原文 24h TTL)、enrich_attempts(友链补全节流),均并入数据管理分项清除;MemoryCache 新增 homeTypes/homeTags/archiveBlogs + 搜索结果缓存(5min/同词去重)。消息页私信历史与管理员用户列表状态迁入 ChatSocket 单例(REST 私信 30s/用户列表 60s 节流,Tab 切换/进出会话不再重建丢态、不重复拉)。首页 selectedYear 入 VM、loadLatest generation 防筛选竞态;搜索输入即搜+350ms 防抖(对齐 web);留言板发送后本地回显 addMessageLocal(对齐 web ADD_MESSAGE);文章/随笔详情正文+评论与文库原文落盘冷启动秒显。
  4. Markdown:mikepenz/multiplatform-markdown-renderer 0.31.0(m3 + coil2 + code 高亮模块)。Markdown.kt 只是薄封装:预处理(HTML 实体解码 + 相对 URL 绝对化)→ Markdown(...)。代码高亮 = markdownComponents(codeFence/codeBlock → MarkdownHighlightedCodeFence/Block),Atom 主题随深浅色(surface.luminance() 判断);表格列宽 markdownDimens(tableCellWidth = 220.dp) 让宽表自动进入横向滚动(库内建,列少时会 fillMaxWidth 不滚)。typography 必须覆盖:库默认 h1=displayLarge(57sp) 巨大,已按 web blog.css 收敛为 26/22/19/17sp。
  5. 文章详情导航:正文按块级切分渲染(splitMarkdownBlocks),标题块 itemIndex 即锚点;TOC 入口 = 详情页右下角 SmallFloatingActionButton → ModalBottomSheet 列表 → animateScrollToItem(blockIndex + 1)(+1 跳过头部 item)。顶部 LinearProgressIndicator 显示阅读进度(firstVisibleItemIndex 占比)。
  6. 部署 hanphone-chat:本机 ssh blog 免密(公钥已装);源码在仓库 chat-server/(原 ~/server_chat/src),pm2 进程名 hanphone-chat-server(跑 dist,改完需 pnpm --filter chat-server build 再 pm2 restart hanphone-chat-server)。协议变更先看仓库 docs/chat/android-protocol.md。

9. 给新 AI 的规矩(Rulebook)

  1. 先读网页再动手:任何接口/交互先看 web/ 与 hanphone-chat/ 的代码,按网站行为实现。
  2. 逐条解析需求:用户说“面板从上往下”先确认是“弹出方向”还是“内部排列”,再动手;不确定就问。
  3. 保持既有规范:双主题/非卡片化筛选/朋友圈随笔/紧凑控件/网站图标 —— 别“顺手美化”成别的风格。
  4. 安全第一:所有网络调用 try/catch;聊天连接钩子保留;分页/认证参数查第 3 节表。
  5. 改完必验:构建→装机→uiautomator→崩溃检查,一条不能少。
  6. 聊天心跳:服务端 60s 无 heartbeat 事件强制断连(Web 端 25s 发一次)——App 端 ChatSocket.startHeartbeat() 已实现,连接成功后必须启动心跳,否则约 1 分钟必掉线(表现为”连接失败/历史消息加载失败”循环)。
  7. 401 拦截器:ApiClient 401 清登录态仅对博客 API 生效;chat-api(独立服务,鉴权失败原因不同)的 401 不能清 TokenStore,否则聊天偶发失败会把用户登出。
  8. UI 组件尺寸:M3 OutlinedTextField 最小 56dp、Button 最小 40dp,不要强制更小(label 会被挤压);小字标签用 labelSmall(自带 16sp 行高),只改 fontSize 会继承 bodyLarge 的 26sp 行高导致间距过大。聊天输入栏用 BasicTextField 胶囊(44dp)。
  9. 加载体验:新页面首载用骨架屏(ui/components/Skeleton.kt,勿用全屏转圈);列表/详情返回秒显靠 MemoryCache + 静默刷新;”我的”页支持下拉刷新。 9a. 长按选择文字:正文/评论/留言等可读文本统一包 SelectionContainer(博客正文按块、随笔正文、评论/留言内容);「点内容=回复」的评论/留言内容用 Modifier.pointerInput { detectTapGestures { ... } } 与 SelectionContainer 共存(点=动作、长按=复制)。
  10. 网页版文库能看、App WebView 白屏:文件服务(admin-file,hanphone.top)对所有文件响应带 Content-Disposition: attachment——WebView 直接 loadUrl 会把页面当下载,主框架 200 后立刻跳 about:blank 白屏(onPageStarted 不触发、子资源不加载、无 onReceivedError)。修复:先 fetch 文件文本再用 loadDataWithBaseURL(baseUrl=文件目录, html, "text/html","utf-8",null) 渲染(网页版 docLoader 也是 fetch 字节渲染,不是直接导航)。文库 .md 预览同理会 fetch 文本。判断「附件下载 vs 页面」看响应头 content-disposition。
  11. Toast 原则:非必要不弹。成功且 UI 已有可见反馈(列表插入新条目、点赞数字变化、头像预览更新)一律不弹;失败、表单校验、登录态提示才弹。聊天 socket 的连接失败走 ReconnectBanner,不发 toast。
  12. 软键盘适配(重要,勿用裸 imePadding):Manifest 里 MainActivity android:windowSoftInputMode="adjustResize"。Scaffold 已按系统 insets 预留底部导航栏空间,若对底部输入再直接 imePadding() 会「双抬升」——键盘上方出现整段导航栏高的空白(曾出现 ~1200px 缝隙)。统一改为 imeLiftAboveKeyboard()(ui/components/Common.kt,= (IME − 导航栏) 内边距 差值,键盘弹出时恰好贴齐键盘顶),覆盖场景:
    • 文章评论 / 留言板底部输入条(CommentInputBar)
    • 随笔列表容器(Box(...imeLiftAboveKeyboard()),内嵌评论胶囊输入不被键盘遮挡)
    • 聊天会话页 Column(主 Tab,键盘弹出时底部导航栏自动隐藏——App.kt 里 imeVisible 判断 WindowInsets.ime,避免主 Tab 底栏占位挡住输入;消息 Hub 的聊天室与私信两个输入栏都要加)
    • 登录/注册表单容器(垂直居中表单上移) 验证:adb shell dumpsys window displays | grep 'type=ime' 可查键盘顶位置,配合截图像素核对输入条是否贴齐。