应用说明 追番记录 HarmonyOS NEXT
关于「追番记录」
「追番记录」是一款 HarmonyOS NEXT 原生 的追番记录应用:搜番、管理收藏、
跟进章节进度、看每日放送,再把观看轨迹沉淀成自己的统计。数据来自 Bangumi 公开 API(v0),
账号与收藏都直接读写你在 Bangumi 上的数据,不做二次托管。
能做什么
搜番与浏览 :关键词搜索、按排名 / 季度 / 类型浏览、标签索引。
追番收藏 :想看 / 看过 / 在看 / 搁置 / 抛弃,进度精确到话,星标评分。
每日放送 :按星期分组,当天放送的条目一眼看全。
时光机 :收藏量与观看轨迹的统计视图、时间线、照片墙。
离线可用 :本地缓存 + 国内只读静态快照,弱网与断网下首屏依然有内容。
边界与取舍
本应用是第三方非官方客户端 ,与 Bangumi 官方无隶属关系;条目数据、评分与评论的著作权归 Bangumi 及其用户所有。
不提供在线播放、下载或任何影视资源;只做“记录与管理”。
华为是番迹主账号。绑定服务验证华为授权,并加密保存 Bangumi 续期凭据,用于同一主账号在新设备恢复官网连接;本机凭据保存在系统资产库。个人档案不上传至绑定服务,专属本地记录请先导出备份。
账号验证与绑定使用番迹固定账号服务;官网收藏同步连接 Bangumi,公开资料与封面可由固定只读服务提供。账号与个人写请求不经过公共数据中继,不提供自定义服务器地址。
应用名称 追番记录
运行平台 HarmonyOS NEXT(Stage 模型)
技术栈 ArkTS / ArkUI · 状态管理 V2
数据来源 Bangumi API v0(bgm.tv)
传输方式 端内 ECH / 官方直连
应用备案 已在应用内「关于」页展示
本站的技术文章,全部来自这款应用的开发与上线过程 :为什么这么做、踩了什么坑、后来怎么改。
应用在信息架构与排版上参考了开源项目 czy0729/Bangumi,出处与许可全文见开放源代码声明 。
本站只发布技术文章,不提供下载入口,也不收集访客信息。
2026-09-18 网络 ECH
端内 ECH:在 DNS 被污染的网络上把请求带回官方域
这类应用的网络问题往往不是"服务端挂了",而是域名解析在到达服务端之前就已经错了 :
官方域被污染时,请求根本没机会建立连接。早期版本的做法是默认走一个社区反向代理 ——
能通,但它同时意味着:请求路径上多了一个能看见完整头部(含 Authorization)的中间人,
而且登录态这类依赖 Cookie 的行为会在代理链路上悄悄失效。
最终方案是把 Encrypted Client Hello 做进应用进程本身 :应用内部起一个本地代理,
用它和官方源站建立 ECH 连接,业务代码照旧请求原来的域名。整条链路上只有两段 TLS:
应用 ⇄ 本地代理、本地代理 ⇄ 官方源站,第二段带 ECH,客户端握手里不暴露真实 SNI。
三通道隔离:一条链路坏了,不能拖垮其他
API、网页、图床是三个不同的入口,故障特征也完全不同 —— 图床被污染不代表 API 不可用。所以熔断按通道分别记账,而不是一个全局开关:
apiSuspended // /v0 接口:失败两次 → 暂停 60s,期间直接走官方直连
webSuspended // bgm.tv 网页:登录 / 验证码 / 吐槽区
imageSuspended // 图床:封面与头像
ech.successes > 0 // 选了 ECH 路由、且业务请求成功
native.successes > 0 // ECH 协议层真的协商成功(更强的证据)
这里有一条容易被忽略的纪律:"业务成功"不等于"ECH 生效" 。一次请求可能选了 ECH 路由、
握手失败、降级直连后成功 —— 结果仍然是成功。所以自检面板把两个计数分开显示,
判断链路健康一律以 native 侧的成功数为准。
结论 网络层的复杂度应该收敛到"路由 + 熔断 + 取消"三件事上,且每一件都要有独立读数。可观测性做在前面,后面所有排障都会变成看数字。
2026-09-18 缓存 SWR
三级取数与 stale-while-revalidate
首屏能不能"立刻有东西",取决于你能不能接受先画旧的 。这个应用把公共数据的取数固化成三级:本地磁盘缓存 → 国内静态快照 → ECH 直连官方。每一级只做一件事,且必须能独立失败。
loadHome(onLocal, onPartial) {
cached = LocalCache.read('home') // ① 本地
if (cached && freshEnough(cached, SOFT_TTL)) return cached // 窗口内完全不出网
snap = PublicSnapshot.fetch('home.json') // ② 国内静态快照(只读、固定文件名)
if (snap) return parse(snap)
return officialHome(...) // ③ ECH 直连官方,分片回调
}
关键是 soft TTL :磁盘上那份数据即使还没过期,也未必值得再走一次网络;反过来,
它过期了也不该立刻丢掉 —— 先画出来,再在后台刷新。这就是 stale-while-revalidate 的全部内容,
难的不是概念,而是把"新鲜度判断"收敛到一个地方 。
写盘必须原子,读要认得自己的写
缓存文件用「同目录临时文件 + rename」做原子替换:读到的一定是完整的旧版本或完整的新版本,不会出现半截 JSON。信封里带版本号与 owner,格式一变就整体丢弃,而不是做兼容解析。
另一个容易翻车的地方是合并写 :把写盘从调用栈上移走能省 IO,但会引入"刚写完又读却 miss"的时序问题 —— 所以延迟写必须同时提供一张待落盘覆盖表 让读取先查它。只接写、不接读 ,是把优化做成 bug 的经典姿势。
结论 缓存不是"存一份数据",而是三件事:新鲜度怎么判、失败怎么回落、写坏了怎么自愈。把这三件事写进一层公共设施,页面就不必各自发明缓存。
2026-09-20 性能 列表 图片
长列表的帧率账:虚拟化、图片队列与离屏作废
长列表的性能问题通常不在"渲染慢",而在一件件小事的叠加 :一屏 30 张不同的封面同时开始下载,紧接着一起解码,于是网络、CPU、磁盘 IO 一起挤在滑动的那几百毫秒里。
先限流,再预取
"同一个 URL 不要下两次"只解决去重,解决不了"30 个不同 URL 同时冲出去"。所以先给图片管线设两条硬上限:下载 6 并发、解码 2 并发 (解码是纯 CPU,和 UI 线程抢时间片,必须比下载更严),再按优先级出队:当前屏大图 > 列表缩略图 > 预取。顺序不能反:没有取消能力就做预取,等于把无效工作提前做。
被平台语义推翻的取消方案
最直觉的写法是在组件的卸载回调里取消取图任务。实测之后发现这基本不生效 :长列表用的是带虚拟滚动的循环渲染,而它默认开启节点复用 —— 子组件移出组件树时是"存入缓存池",不是销毁;复用时也不会触发回收/复用回调。滑出屏幕这条主路径上,卸载回调根本不会响。
// 错的:滑出屏幕不销毁 → 不触发
aboutToDisappear() { this.cancelTask() }
// 对的:可见性变化覆盖"划入/划出屏幕、页签切换、组件卸载"
.onVisibleAreaChange([0.0], (isVisible: boolean) => {
if (isVisible) { this.everVisible = true; return }
if (this.everVisible) this.cancelTask() // 守卫:预加载节点拿不到回调
})
everVisible 那个守卫不是洁癖:预加载区域的节点拿不到可见性回调,注册时还可能先来一个"不可见",没有守卫就会在注册那一刻把刚发出的请求取消掉,图永远出不来。另一个细节是不能按 URL 取消 :同一张封面可能同时被两个组件持有,一个离屏不能误杀另一个 —— 所以按任务引用计数取消。
结论 离屏作废是一条安全网 ,不是热路径收益:设备快、网络快时它无事可做(实测解码 6–7ms 时队列根本堆不起来);真正兑现是在冷缓存 + 慢网那种"队列真的堆积"的场景。
2026-09-18 可观测性 排障
把"到底通不通"从猜变成读数
弱网问题的排障成本,几乎全部来自看不见 。曾经有一类 bug 是这样的:同一屏里有的图能出来、有的出不来 —— 直到把两件事并排显示才看清:正常的那几张走了应用自己的传输层,不正常的那些是渲染组件自己发起的直连 ,绕开了进程内的 ECH。
该记什么
路由分布 :ECH / 官方直连 / 降级次数,分开记,别合成一个"成功率"。
熔断状态 :哪个通道被暂停、连续失败几次、还剩多久恢复。
图片管线 :在途、各优先级待办、历史峰值、解码闸门占用、内存与磁盘命中。
第二级缓存的健康 :请求数、传输失败、响应异常、解析失败、被熔断挡下的次数。
不该记什么
不记 URL、不记域名、不记搜索词、不记任何令牌 —— 自检页是给自己看的诊断面板,没有理由把用户数据留在上面。这条边界写进注释,免得后来者"顺手"加上。
还有一个工程细节值得说:队列深度这类瞬时值要额外记"历史峰值" 。快速滑动之后待办数会立刻归零,等你切到自检页早就什么都看不到;峰值把那一刻留下来,于是"要不要做取消/预取"从"得盯着看"变成"事后可测"。
结论 装了仪表盘就要把表盘朝前。没有读数的优化只能凭感觉,而凭感觉的判断无法在下次回归时复用。
2026-09-20 合规 开源
把合规做成工程:备案、许可证与"站点零外部请求"
合规最容易变成"上线前补的一堆文档",但其中相当一部分是可以被工程化 的:写成常量、接进构建流程、用脚本验收。
三个容易混淆的编号
主体备案号 :备案主体的编号,网站与 App 都挂在它下面。
网站备案号 :主体号 + 网站序号(…-2),展示在网站页脚,并且必须链接到工信部备案系统 。
APP 备案号 :另一个编号(…-3A),与网站备案同主体但单独提交 ;上架应用市场要的是它。
MIT 全文该出现在哪些副本里
参考了以 MIT 发布的第三方开源项目时,许可证原文要求版权声明与许可声明包含在本软件的所有副本或主要部分中 。所以它不是一个可有可无的致谢:应用内的「关于」页要有可展开的许可证全文 ,网站也要有一份。只保留出处的第三方接口(例如未附许可证的 API 契约)应当写明"不作再许可"。
为什么这个站点一个外部资源都不加载
整站是单文件:CSS 内联、图标用内联 SVG、没有 CDN、没有外链字体、没有统计脚本。直接好处是审核期与线上都不产生第三方流量 ,弱网下也只剩一次 HTML 传输;代价是牺牲了一些便利,但这个站点本来就只需要展示。
结论 合规里凡是能写死的就写死、能用脚本验的就验(备案号、许可证全文、外部请求数),剩下的才是真正需要人工判断的部分。
2026-09-15 排障 冷启动
登录成功却看不到收藏:一次冷启动竞态
现象 :用户明明登录过,冷启动后界面显示"未登录",而且这一整个会话都不会恢复 —— 切页、刷新、重进设置页都没用,必须杀进程重开。
排查 :先怀疑 token 没存住,但直接读本地存储是有值的;再怀疑接口失败,可日志里根本没发出请求。把启动序列打点之后看出来了:读 token 的那一步跑在了"首选项 store 打开"之前。
根因 :启动时有两件事并行 —— 初始化本地存储(异步)与预读外壳状态。预读用了"没准备好就返回默认值"的写法,于是 token 被读成空串并缓存进了内存状态 ;又因为预读是幂等的、只跑一次,后续谁也不会再读第二次。
// 错的:并行 → 可能在 store 就绪前就把 token 读成空串
preloadShell();
Preferences.init(ctx);
// 对的:init 完成之后再预读,读到的才是真值
const ready = Preferences.init(ctx);
ready.then(() => appStore.preloadShell());
为什么值得写下来 "读成空串"比"读失败"更难查:它不抛错、不进日志,而且结果被缓存 ,把一次时序错误变成了持久状态。凡是"幂等 + 只跑一次"的初始化,都要检查它依赖的前置是否真的就绪。
结论 启动阶段的并行要按依赖分层:先"就绪",再"预读",最后"挂页面"。三者顺序写死,别靠运气。
2026-09-15 排障 图片
一行 Image(url) 让一半封面消失
现象 :同一个页面里,收藏列表的封面正常,另一处的海报却全是占位图。看起来像"图片加载不稳定",但分组非常干净:能用的一律是某个组件渲染的。
排查 :抓包发现两类请求的来源不同 —— 出图的那批走应用自己的网络层(带端内 ECH),不出图的那批根本没走应用网络层 。
根因 :渲染组件的图片能力会自己发起连接 ,绕开应用的传输层。在官方图床被 DNS 污染的网络里,这条直连必然失败;而同一时刻走应用传输层的图片完全正常 —— "有的能出、有的不出"的分裂就是这么来的。
// 错的:组件自己直连,绕开端内 ECH
Image(subject.coverUrl)
// 对的:统一走缓存组件 → 应用网络层
CachedImage({ url: ImageUrlResolver.cover(subject.cover, widthVp) })
// 只有本地资源与已解码的 PixelMap 可以直接交给 Image
Image($r('app.media.placeholder'))
Image(pixelMap)
结论 远程图片必须只有一个入口。把"加载逻辑只保留一份"写成硬约束并落进代码注释,否则下一个人还会顺手写 Image(url)。
2026-09-15 性能 图片质量
列表清晰、详情发虚:封面质量必须在 URL 层决定
现象 :列表里的封面很清楚,点进详情页后同一张图放大就发虚,像蒙了一层。
排查 :量了一下尺寸才发现问题不在解码,而在请求的档位 :列表用的是最便宜的那一档,实际像素只有 78×100;详情页把它铺进 351×506 的框里,等于 4.5 倍上采样 —— 任何解码器都救不回来。
修法 :把"要哪一档"变成请求前 的决定,而不是渲染时的缩放。每一处封面按自己的显示宽度去问同一个解析器,得到对应的档位 URL:
// 档位在 URL 层选好;解码尺寸另算(显示尺寸 × 设备像素密度)
CachedCover({
url: ImageUrlResolver.cover(src, CoverFrames.LIST.widthVp),
coverWidth: CoverFrames.LIST.widthVp,
coverHeight: CoverFrames.LIST.heightVp
})
版面比例与取图档位必须成对 :只改版面不改档位 = 图变糊。
解码目标不要写死 ×2,用真实设备密度。
档位不是越大越好:原图一档 916 KB,中档 54 KB —— "最小可覆盖需求的档位"才是对的。
结论 清晰度是请求阶段 决定的,渲染阶段只能毁掉它、不能修复它。
2026-09-14 排障 ArkUI
设置页背景和上一屏重叠
现象 :从时光机切进设置页时,新页面的背景是透明的,上一屏的内容直接透了出来,两层文字叠在一起;页面标题还比设计稿低了一截。
根因 :导航目的地用了透明背景却没有自带"光场层",于是它只是一个漂浮的内容层;同时这个页面隐藏了系统标题栏、自绘了顶部栏,而外层容器仍然按"系统标题栏存在"给它留了 52vp 的让位 —— 两个问题各自独立,叠在一起就成了"重叠 + 偏低"。
// 统一用一个光场外壳包住目的地:背景光效 + 安全区让位都在这里
NavSurface({ topInset: 0 }) { // 自绘顶栏的页面必须传 0
Column() { this.topBar(); this.body() }
}
为什么值得写下来 这类问题看一眼以为是一个 bug,其实是两个:"透明没有底" 和"让位算重了" 。修掉其中一个,另一个还在,就会让人以为"没修对"。
结论 凡是透明页面,都必须自己带背景层;凡是自绘顶栏,都必须显式声明"不要再给我留标题栏的位置"。
2026-09-16 排障 凭据
凭据搬家搬错顺序:一次失败就永久丢登录态
背景 :早期版本把登录令牌存在普通的首选项里。改成存进系统资产库(加密、不进云备份)时,需要一次迁移:把明文读出来 → 写进资产库 → 删掉明文。
坑 :第一版写成"先删明文,再写资产库"。看起来只是顺序差异,但资产库写入是可能失败的(设备策略、密钥不可用)—— 一旦失败,明文已经删了,用户就永久丢掉了登录态,且无法恢复 。
// 错:先删后写 —— 写失败就两边都没了
removePlaintext();
await assetStore.write(token); // 可能失败
// 对:确认写入成功之后再清理明文;失败就原地保留,下次再来
const ok = await assetStore.write(token);
if (ok) removePlaintext();
附带一条同源的纪律:资产库只有 secret 字段是加密的 ,别名与标签字段不是 —— 所以永不要把密钥写进标签里。
结论 任何"搬家式"迁移都要满足:新位置写成功,才清理旧位置 。这条规则与具体存储无关,写进代码注释比写进文档更管用。
2026-09-20 排障 工具
两个把我骗过的假信号
这两个都不是产品的 bug,而是测量工具的 bug ;但它们都让我先得出了错误结论,所以值得记。
假信号一:本机 curl 报 000,我以为是服务器挂了
给站点配好证书后,用本机 curl 测 HTTPS,五个端点全返回 000,看起来像"TLS 根本握不上"。差点去改服务器配置。真实原因在客户端本地:命令行工具用的是系统 TLS 栈,在受限会话里连凭据都拿不到 ,在发出第一个字节之前就失败了。换一个自带 TLS 实现的客户端,同一时刻同样端点全部 200 。
// 不可信:受本机环境影响,失败可能发生在发包之前
curl -o NUL -w "%{http_code}" https://example.com/
// 可信:用自带 TLS 栈的探针,失败才真的是网络/服务端问题
node scripts/probe-site.mjs --n=6
假信号二:测试"通过"了,但用例其实是失败的
构建工具在用例失败时仍然返回退出码 0 (实测"失败 1 条"而最后一行写着 BUILD SUCCESSFUL)。只看退出码,CI 与本地都会得到"全绿"的假象。修法是两件事一起做:跑之前先删掉上一次的结果文件 (编译失败时不会写新文件,检查器会去读旧的),跑之后用一个检查器读结果文件 而不是看退出码。
# 编译失败看退出码(可靠);用例失败看检查器(退出码不可靠)
npx hvigorw test --mode module -p module=entry@default
node scripts/check-unit-test-result.mjs # Tests run 131, Pass 131
结论 当结论是"环境坏了"时,先问一句这个信号本身可信吗 。判据要挑那种"失败只可能来自被测对象"的工具,否则你测量的其实是自己的机器。
「君梦的技术笔记」是一个纯展示的技术博客,内容围绕一款鸿蒙原生应用的开发与上线过程。写下来的标准只有一条:下次遇到同类问题时,这篇文章能直接给出结论与判据。
站点名称 君梦的技术笔记
内容方向 HarmonyOS NEXT 应用工程实践
更新方式 随开发过程增补,按主题成篇
页面形态 单文件静态页 · 零外部请求
提及的应用 追番记录(HarmonyOS NEXT)
写决策过程 而不只是结论:为什么这么选、代价是什么、后来被什么推翻。
写判据 而不只是按钮位置:能变成读数或脚本的,就不留成"经验"。
本站只发布技术文章,不提供在线播放、下载或任何影视资源,也不收集访客个人信息。
文中提到的那款应用在信息架构、页面组织与排版上参考了开源项目
czy0729/Bangumi 。
该项目以 MIT 许可证发布,按许可证要求,下面附上其版权声明与许可全文。
czy0729/Bangumi —— MIT License,Copyright (c) 2019 https://github.com/czy0729。应用参考其信息架构与排版,未复制其运行时代码。
Bangumi API v0 (github.com/bangumi/api)—— 条目数据与账号能力的来源;上游仓库未附许可证文件,此处仅保留出处,不作再许可。
端内 ECH 传输层 使用 Rust 生态组件(rustls / ring / openssl / parking_lot / rcgen / base64 等),各自遵循 MIT、Apache-2.0 等宽松许可证;HarmonyOS SDK 与系统组件遵循华为相应条款。
MIT License
Copyright (c) 2019 https://github.com/czy0729
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.