用途:供新接手 AI 快速理解项目、规避前人踩过的坑。 位置:
android/。配套文件:README.md(构建)、本文件(经验与规矩)。 铁律:改动前先读web/(网站前端)与docs/chat/(聊天协议,原 hanphone-chat 已并入本仓库)作为接口与交互的”唯一事实来源”,不要凭直觉假设。
博客「云林有风」的 Android 原生客户端。Kotlin + Jetpack Compose (Material 3),对接博客 REST API 与 hanphone-chat 实时聊天。后端代码零改动。
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 双主题
MemoryCache(同步,返回秒显)→ ContentStore(磁盘,冷启动秒显)→ 网络(始终刷新)。mutableStateOf 暴露状态(private set),UI 直接 vm.xxx 读取;页面内不再 remember 业务状态。AppGraph.xxxRepository),测试时可替换。viewModel(key = "article_$blogId") { ArticleDetailViewModel(blogId) }。var mode(private set)与 fun setMode() 会 JVM 签名冲突,方法名换一个词。https://hanphone.cn/api/(nginx 剥掉 /api 前缀)Result<T>:{ flag, code, message, data }X-Request-Id 头(ApiClient.newRequestId())md5(明文) 再发送(与 web/src/components/LoginForm.tsx/RegisterForm.tsx 完全一致;后端 checkUser 校验的是 bcrypt(md5(明文)))。App 端工具:util/Crypto.kt md5Hex()。data = { user, token, expire };expire 为 Date(毫秒数字或字符串),模型里用 String? 兼容两种。User.type:"1"=管理员、"0"=普通用户 —— 登录后必须存入 TokenStore.userType 并 DataStore 持久化,消息页私信按它分流。Token(ApiClient 自动注入);聊天 REST 用 Authorization: Bearer <token>(BlogApi 对应的 @GET 里手动带)。TokenStore.clear()(避免“旧 token 卡死登录入口”)。/blogs:pagenum 1 起始/types/{id}、/tags/{id}:Spring page 0 起始/essays、/messages:page 1 起始userId(后端取本人资料),无昵称/邮箱输入(repo.postCommentAsUser)。旧匿名(nickname+email)请求仅后端兼容保留,App 不再使用。parentId(postCommentAsUser(...parentId));后端 Comment 的 parentComment 被 @JsonIgnore(列表不回传父级),新回复的「回复 @x」标签由 UI 本地 replyHints 记录展示,历史回复呈扁平列表(与网页 GET 行为一致)。评论内容可长按选中复制(SelectionContainer + detectTapGestures 共存:点=回复、长按=选择)。userId);列表项与详情页均支持回复(parentCommentId 楼层回复,弹窗「回复 @昵称」)。{ userId, blogId/essayId, isLike }。/essays/{id}/comments 也需要 X-Request-Id。POST https://hanphone.top/upload/avatar(免鉴权,multipart 字段 avatar,≤5MB,IP 限频)→ { code, message, url }。FileApi 独立 Retrofit(域名不同、无 /api 前缀)。https://hanphone.cn + path /chat-api/socket.io(socket.io v4 / EIO=4)。authenticate(blogJWT),否则被断开。requestPublicHistory→publicHistory;publicMessage→publicMessageBroadcast;publicOnlineCount;AI:publicAiStreamStart/Chunk/End/Error(@寒枫 触发);messageDeleted。userMessage(普通用户发给管理员)、adminMessage(userId, content)(管理员回)、message(收到私信)、userListUpdated(管理员在线用户)。markConversationRead(peerUserId)(普通用户=1000,管理员=对方 userId);收到 conversationRead { peerUserId, readUpToMessageId } 时把自己发给对方、id<=readUpToMessageId 的消息标已读(ChatSocket 单例内存缓存 + 修订号 privateCacheRevision 驱动 UI 刷新)。管理员收件箱未读数 = REST GET /chat-api/api/users/unread(60s 节流)+ socket 新消息实时增量(未在查看该会话时 +1、查看时即时标记已读)。协议见 docs/chat/android-protocol.md。ui/App.kt 的 BlogAppRoot(登录态变化即 ChatSocket.connect/disconnect),不必先打开消息页;ChatSocket.adminUnread(userId→未读数,两角色通用)非空即底部「消息」Tab 显示红点(只红点、不显数字),管理员冷启动时 App.kt 预取 /users/unread 校正。私信通知条件 =「消息页不可见 或 App 不在前台」→ 后台/离开消息页都弹(ChatSocket.appInForeground 由 MainActivity.onResume/onPause 维护)。data/push/ 包。PushRegistrar 启动/前台恢复/onNewToken 时取 FCM token → 同时上报 博客 server POST /api/devices(热点推送;Token 头由拦截器注入关联用户)与 chat-server POST /chat-api/api/devices(私信推送;带 Bearer);BlogMessagingService 收前台消息并展示。热点更新由博客 server 直接经 FCM 广播(data.type=hot,渠道 hot_updates;过渡期博客无 token 时兜底走 chat-server /chat-api/internal/push);私信推送由 chat-server 在「接收方无在线 socket」时才发(与 socket 通知去重)。凭证:android/app/google-services.json(缺失时 BuildConfig.FCM_CONFIGURED=false,FCM 自动禁用、构建仍成功)+ 服务端 FCM_SERVICE_ACCOUNT。GET /chat-api/api/messages/admin;管理员 GET /chat-api/api/users/all + GET /chat-api/api/messages/{userId} + GET /chat-api/api/users/unread。/messages/admin 对管理员返回 403;/users/all 对普通用户返回 403 —— 所有这类调用必须 try/catch,否则 Retrofit HttpException 会崩掉整个 App。useMessages.publish 里作者 = 登录 nickname,未登录固定 "匿名用户",无语名输入框。"匿名用户",头像 = 登录头像(repo.postMessage(nickname, content, avatar, parentId) 已支持)。parentMessage(非 @JsonIgnore),列表直接展示「回复 @父昵称」标签。留言内容可长按选中复制。分类:xx ✕ / 标签:xx ✕ / xxxx年 ✕,可单个移除)+「清除」SuggestionChip(无筛选时不占位)。zIndex、offset(58.dp),放在内容 Box 之外)——不参与布局、不顶动内容。HorizontalDivider 插在同一条目 item 内 index>0 时渲染、上下各 4dp、条目间隔 spacedBy(6.dp)、列表内容 padding 上下 6dp——保持紧凑的「分割页」不留太多空白)。Favorite/FavoriteBorder)、写评论(Create)、评论折叠/展开(Email)。图标可点击区域 18dp/段内 8dp。没有评论的随笔不再渲染评论区(不显示”还没有评论”空态;评论数据经 VM essayComments 到达后自动出现)。CommentInputBar + autoFocus),评论数据收在 VM(essayComments 状态 map),提交后即时回显;点某条评论同理在底部输入条进入「回复 @昵称」模式。随笔详情页仍用弹窗输入(EssayCommentDialog,minLines=2)。core/Settings.kt 的 draftFlow/saveDraft,kind 如 article_comment_{id} / message_comment / essay_comment_{id} / essay_detail_comment_{id});输入停顿约 400ms 落盘、发送/清空即删;不随「数据管理 → 清除全部」删除。PullToRefreshBox.isRefreshing = refreshing && userPulled(首屏冷加载/骨架不弹 Refresh 圈,仅用户主动下拉才有)。EssayFileUrl.displayUrl() 给列表九宫格用(优先 thumbPath,缺失回退 url),originalUrl() 给详情页大图用。原图单张可达 12MB、单篇最多 9 张(实测合计约 40MB),直出会同时打爆 App 和 2C2G 的服务器。老数据与外链附件 thumbPath 为 null,自动回退原图,行为与改造前一致。essayFileUrls 的 urlType 实际取值是 IMAGE/VIDEO/TEXT/OTHER(全大写)。判断视频必须用 equals("video", ignoreCase = true),写成 != "video" 条件恒为真,视频会被当图片丢进 AsyncImage。TabRow[聊天室 | 私信]。TokenStore.isAdmin 分流:普通用户 = 与「寒枫(博主)」单聊(/messages/admin + userMessage);管理员 = 用户收件箱(/users/all + socket userListUpdated,在线绿点)→ 点入用户会话(/messages/{userId} + adminMessage,带返回)。chat_public.json)+ 私信会话(chat_private_{admin|u{id}}.json)+ 私信用户列表(chat_users.json,管理员收件箱秒显)存 ContentStore;进入消息页先秒显缓存,socket 连接在后台刷新(ChatSocket.restoreMessages + 写缓存节流 5s、断线强制落盘)。首进不再全屏「连接中」,仅顶部细横幅提示。notification:"认证成功,已连接到聊天服务器"(server.ts:519),已在 ChatSocket 源头过滤(startsWith("认证成功") 不进入 notice),避免每次进消息页/重连都弹认证成功 toast;其余错误/操作类 notification 仍会保留提示。ui/hot/(对标 web /insight)。三视图:热点(分类 chips 全部/GitHub 热门/HF 趋势/AI 要闻 + 中文优先卡片,点击开链接)、榜单(模态 chips,高密度行:排名/变化 + 模型 + 厂商·价格·上下文 + 各榜单分数,细分割线分隔 + Canvas 手绘近 30 天折线趋势;排名=排序位置,无排名时用序号兜底;每个模态独立 LazyListState,切换领域从头开始、不带着旧滚动位置跳到同名模型)、对比(紧凑工具栏 + 底部弹层选择器 按榜单 / 按公司 / 搜索,可选模型全部来自 /hot/leaderboards 与 /hot/models,最多 6 个;默认选六家代表厂商各自最强:DeepSeek/Z.ai(Kimi 之外)/Kimi/Anthropic/OpenAI/Google(取自文本榜 AA 智能指数排序);「指标 × 模型」逐行高亮最优值、只显示至少一个模型有分的口径;分享走系统 Intent → hanphone.cn/insight/?view=compare&models=...)。顶部有「名称归一」开关(默认开):关闭时厂商名与模型行变体/日期后缀按信源原始展示(Alibaba-ATH、z-ai、claude-opus-4-5-20251101),实体合并不受影响。公开接口 /hot/*(无需登录),三层缓存(hot_*.json)并入数据管理「热点聚合(洞察)」分项。MarkdownContent 渲染(原先是纯文本,Markdown 不显示);正文高亮/标题正常。FeaturedModelRegistry 与 /hot/models/featured 已彻底删除(FeaturedGroupVO/FeaturedModelVO 一并移除)。hot/model/VendorRegistry):/hot/vendors 返回归一后的公司(大小写/连字符/子品牌别名合并,如 Alibaba/Alibaba-ATH/Qwen → Alibaba、z-ai/Z.ai/Z-AI → Z.ai、Mistral/mistralai、SpaceXAI/x-ai → xAI、~ 前缀剥离);重点厂商置顶:Anthropic、DeepSeek、OpenAI、Kimi、Alibaba、Z.ai、Google、Xiaomi、ByteDance。/hot/models?vendor=&sort=newest|name|price|context&limit= 支持按公司/关键词取模型并排序(默认越新越前);模型行的 vendor 也已归一显示。https://hanphone.cn/atlas/(nginx alias 托管,页面自带瀑布流/便利贴/时间线展示与筛选),路由复用 webview?url=...(App.kt 的 onOpenPhotoWall)。登录态由 core/AuthBridge.kt 注入,与 App 共用(详见坑点 15)。Atlas.vue 早期只有空态没有加载态,atlasData 初始 [] 导致接口返回前先闪「没有符合筛选条件的照片」。App 里必现——每次进入都是全新页面实例,而 WebView 的 sessionStorage.atlasWelcomeShown 会持久化,第二次起欢迎页被跳过,没有东西遮挡这段空窗(/show 冷启动实测约 2.3s)。已加 isLoading(初值 true,只在 finally 置 false)。WebView 侧改这类「异步数据 vs 空态」时注意同样的坑。sessionStorage.atlasWelcomeShown 控制,domStorageEnabled = true 让它在 WebView 里跨会话持久化,所以第二次起直接跳过欢迎页(用户希望保持这个行为)。首次安装/清数据后仍会看到 3s 欢迎页。vite-plugin-pwa 默认生成 NavigationRoute,匹配所有导航且忽略查询串,始终返回预缓存里的 index.html;旧 SW 没换掉之前,App 一直跑旧 Atlas.vue。加上 nginx 未给 sw.js/index.html 配 Cache-Control(被 Cloudflare 按 4h 缓存),发版后旧包可能维持很久。已在 apps/photo-wall/vite.config.ts 去掉 NavigationRoute、入口 html 不预缓存、导航改 NetworkFirst(atlas-pages);SW 文件名带构建版本 + 注册代码内联 index.html,绕过 Cloudflare 对 sw.js/registerSW.js 的 4h 缓存。排查「网页改了 App 里没变化」先想到旧 SW。详见 docs/photo-wall-image-optimization.md。GET /projects(推荐在前、type 分组,与 web ProjectClient 一致);点击卡片用 WebView 内开项目链接(ui/webview/WebViewScreen.kt:JS 开启、加载进度条、系统返回先回退 WebView 历史、右上角菜单可在浏览器打开)。GET /docs,按 docNamespace 构建文件夹树(blog/docs/子目录 → 顶层文件夹,对齐 web DocLoader);文件夹浏览 + 面包屑、名称/路径搜索、类型筛选 pills(Word/PDF/MD/HTML 带计数,对齐 web DocsFilter);文件行 = 彩色类型徽标 + 名称 + 推荐星 + 日期;打开文件:HTML → WebView、MD → 应用内 Markdown 预览(DocMarkdownScreen,文件服务原文去 frontmatter 后渲染)、PDF/DOCX → 系统打开/下载;文件 URL = https://hanphone.top/{docNamespace}/{文件名} 逐段 URI 编码(util/buildDocFileUrl),打开即上报浏览量 POST /docs/{docId}/view。清除全部 弹确认框并连图片缓存一起清,仅清本机缓存不影响服务器)+ 检查更新(行内点击检查,副标题显示当前版本号)+ 关于(App 图标/名称/简介 + 访问博客网站/GitHub 链接)。外观/数据管理是独立子页(路由 settings/appearance、settings/data),设置页只放入口避免过长。账号区已删除(「我的」页已含登录态/退出/资料编辑);访客头像上传同步移除(匿名留言不再可配置本地头像)。scene=general,非管理员必填;管理员免验证码)+ 新密码(可选,≥6 位含字母数字,md5 传输)。保存走 POST /user/current/update(body 根级带 captcha),后端 UserServiceImpl.updateCurrentUser 会对 user.password 做 bcrypt。R.drawable.ic_blog,取自 web PWA icon-512)徽标 + App 名 + 单行简介 + 链接行(访问博客网站 hanphone.cn / 作者 GitHub),点击走系统浏览器。版本号只在「检查更新」区显示一次(不重复)。https://api.github.com/repos/HanphoneJan/hanphone-blog/releases/latest(tag 语义化比较 isNewerVersion(tag, BuildConfig.VERSION_NAME),取 .apk 资产 URL),有新版弹「发现新版本 vX + Release 说明 + 去更新(浏览器下载 APK)/稍后」,无新版 toast「已是最新版本」。API 与下载链在直连网络可用。⚠️ 因为是语义化比较,同版本号重新发布 APK 不会让已装用户收到更新提示——要让用户升级必须同时抬 versionCode。v{versionName}(debug 密钥签名的 release APK,assembleRelease 已配 signingConfig = debug,可覆盖升级;正式签名留 S5)。R.drawable.ic_blog(CircleShape 裁剪),不再用文字「云」。AuthData(token,userId,nickname,avatar,userType)。sendCaptcha scene=register,60s 倒计时,密码≥6 且含字母数字),成功自动登录。AppBackBar:所有子页统一 44dp(替代 M3 TopAppBar 的 64dp,含状态栏后更省空间);搜索条 SearchField 40dp 胶囊(项目/文库/搜索页)。新增页面用这两个组件,勿再引入 TopAppBar/56dp OutlinedTextField 搜索框。background #F2F6FB、surface #FFFFFF、容器蓝调)。windowInsetsPadding(navigationBars) 手势区有底色;图标 24dp、文字 11sp、图标与文字间距 2dp;选中主色)。firstPicture || type.pic_url、分类色点 type.color、essayFileUrls 图、友链头像、启动图标 = 网站 PWA icon-512x512.png(图标放自适应安全区内约 62%,背景白,勿满幅缩放/勿蓝底)。# 构建(必须带代理 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)
~/Android/Sdk(local.properties 指向),JDK 21。adb devices 可见即 device 状态)。用 uiautomator 的 bounds 算中心点做点击。md5(明文)。(最高频)retrofit2.HttpException: HTTP 403 —— 管理员账号调普通用户接口;网络请求必须 try/catch。ui/App.kt 的 BlogAppRoot(登录态建立即常连)——重构时易被删掉;删了就会退回到「必须先打开消息页才连、后台收不到私信通知」。
4a. 整 App 卡加载:VM 化重构时丢掉了页面里的 LaunchedEffect(Unit){ load() } 首次加载调用——VM init 只读缓存不发请求,所有列表页永远停在骨架屏。规矩:每个 VM 的 init 必须自己发起首载,改完用 logcat 确认请求真的发出(adb logcat | grep okhttp)。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 一致。by remember)不能直接 smart cast(用 !! 或局部变量)Modifier.weight 只在 Row/Column/Box 内容作用域里可用object 无法被外部 import —— 写顶层扩展KeyboardOptions 在 androidx.compose.foundation.text(不是 ui.text.input)LocalView.current 只能在 @Composable 里取(先存局部变量再给普通函数用)notification:"认证成功…"(server.ts:519)——已在 ChatSocket 源头过滤(startsWith("认证成功") 不进 notice);且 StateFlow.collect 进页会重放当前值,收集通知要用 notice.drop(1),否则每次进消息页重弹上次通知。LocalImageLoader.current 的 diskCache.size/clear() + memoryCache.size/clear()。坑:MemoryCache.size 是 Int(与 ?: 0L 合并会类型漂移成 Number 编译错,需 .toLong());DiskCache.clear() 是 suspend。R.drawable.ic_blog(拷贝自 web icon-512x512.png)——别用文字「云」当占位。displayUrl(),详情页大图才用 originalUrl()。原图单张可达 12MB、单篇最多 9 张(约 40MB),Coil 会把磁盘缓存撑爆且拖垮 2C2G 的服务器。服务端(admin-file)上传时已生成 320w/800w/1600w WebP,前端只要带上 thumbPath 就够了。照片墙是 WebView,不是原生页:https://hanphone.cn/atlas/ 由 nginx alias 托管的 Vue SPA,图片走浏览器内核加载 + Service Worker 缓存,不受 App「数据管理 → 图片缓存」控制。登录态通过 core/AuthBridge.kt 注入(见下条),不再各存一份。
core/AuthBridge.kt):
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。ProfileScreen 的「退出」:clearAuth() + TokenStore.clear() + AuthBridge.clearWebStorage()。用 WebStorage.deleteAllData() 而不是 evaluateJavascript("localStorage.clear()"):后者要求 WebView 实例存活,而登出时页面往往已销毁;WebStorage 也不会误删 Cache Storage(Service Worker 资源缓存)。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 大概率还没吐值。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。
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,失败模态保留旧数据)。displayUrl(),详情页走 originalUrl();21 张图 74.65 MB → 0.75 MB,100×)、修掉 urlType != "video" 大小写导致视频被当图片加载的 bug。详见 docs/photo-wall-image-optimization.md。core/AuthBridge.kt 在文档开始时把 App 的 DataStore 登录态注入页面 localStorage(仅 hanphone.cn 白名单),登出时同步清理;AuthData 补 username 字段。ui/hot/,对标 web /insight):热点流 / 高密度模型榜单(Canvas 手绘近 30 天折线)/ 综合对比(底部弹层选择器:按榜单·按公司·搜索,结果优先布局 + 分享链接);入口「我的 → 更多」,三层缓存 + 数据管理分项。对比选择器与 web 均已弃用写死的 featured,改由榜单实时构建。v1.0.1 发布:页面更名「热点」、榜单名次=排序位置、切换领域重置滚动、默认对比六家代表厂商、更新弹窗 Markdown 渲染。v1.0.2(hanphone-1.0.2-android.apk,versionCode 16)补充顶部「名称归一」开关(默认开)——关闭时厂商名与模型行变体/日期后缀按信源原始展示(anthropic、Alibaba-ATH、z-ai),实体合并不受影响;后端支持 normalized 参数(仅展示层)。后端榜单改用 AA 代码指数排序 + 模型名归一(变体后缀)+ LMArena 经服务器代理采集(见 server 文档)。未做雷达图与价格散点(App 无图表库,按规矩不擅自引入重依赖)。SavedStateHandle["blogId"/"essayId"],不再手写 factory/key。ApiClient.BlogDateAdapter(毫秒数字/ISO/普通日期);模型注解用 @Json(name=...)(原 Gson @SerializedName 已迁移)。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);文章/随笔详情正文+评论与文库原文落盘冷启动秒显。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。splitMarkdownBlocks),标题块 itemIndex 即锚点;TOC 入口 = 详情页右下角 SmallFloatingActionButton → ModalBottomSheet 列表 → animateScrollToItem(blockIndex + 1)(+1 跳过头部 item)。顶部 LinearProgressIndicator 显示阅读进度(firstVisibleItemIndex 占比)。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。web/ 与 hanphone-chat/ 的代码,按网站行为实现。heartbeat 事件强制断连(Web 端 25s 发一次)——App 端 ChatSocket.startHeartbeat() 已实现,连接成功后必须启动心跳,否则约 1 分钟必掉线(表现为”连接失败/历史消息加载失败”循环)。ApiClient 401 清登录态仅对博客 API 生效;chat-api(独立服务,鉴权失败原因不同)的 401 不能清 TokenStore,否则聊天偶发失败会把用户登出。OutlinedTextField 最小 56dp、Button 最小 40dp,不要强制更小(label 会被挤压);小字标签用 labelSmall(自带 16sp 行高),只改 fontSize 会继承 bodyLarge 的 26sp 行高导致间距过大。聊天输入栏用 BasicTextField 胶囊(44dp)。ui/components/Skeleton.kt,勿用全屏转圈);列表/详情返回秒显靠 MemoryCache + 静默刷新;”我的”页支持下拉刷新。
9a. 长按选择文字:正文/评论/留言等可读文本统一包 SelectionContainer(博客正文按块、随笔正文、评论/留言内容);「点内容=回复」的评论/留言内容用 Modifier.pointerInput { detectTapGestures { ... } } 与 SelectionContainer 共存(点=动作、长按=复制)。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。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' 可查键盘顶位置,配合截图像素核对输入条是否贴齐。