成为全栈·Flutter App 篇·服务端目录与文章辅助阅读:锚点、上下篇、浏览量和阅读历史

发布时间:2026/10/15 16:22:02
成为全栈·Flutter App 篇·服务端目录与文章辅助阅读:锚点、上下篇、浏览量和阅读历史
成为全栈·Flutter App 篇·服务端目录与文章辅助阅读锚点、上下篇、浏览量和阅读历史上一篇解决了目录项怎么绑到标题上。这篇接着说文章页里其余那些看起来都很小、但每个都有自己的坑的功能目录面板、上下篇、浏览量、阅读历史。四个功能四种不同的数据性质功能数据性质请求时机写操作目录正文的一部分跟正文一起无上下篇按发布时间推导跟正文一起无浏览量写多读少、不能重复计进入时上报一次有阅读历史私有、随阅读行为变化本地 同步有前两个是读一次就好后两个是要写、要同步、要防重。这篇的重点在后者以及一个我在实现 id/slug 别名时才想明白的设计。正文和目录必须是一个版本先说一个容易被忽略的前提目录不是独立请求的。/// 已发布正文与目录作为一个版本保存避免目录指向旧内容。classArticleReader{这个类把正文和目录当作一个整体存进同一个缓存键。为什么必须绑在一起设想分开放的情况目录缓存 1 天正文缓存 1 小时。用户读了一半编辑更新了这篇文章——下一次用户刷新正文变成新版了但目录还是旧的。结果就是目录里那些锚点指向的位置全部偏移点一个跳到错的地方。而且这个 bug 极难发现文章短的时候目录只有两项错位也看不出来长文章用户也不会去逐个点。所以现在的策略是宁可一起过期。正文十分钟更新一次目录跟着一起。代价是文章更新后目录要多等十分钟才刷新——这个代价明显小于目录跳转错乱。这个判断的通用形式是当两个数据存在语义依赖时它们的缓存生命周期应该取交集而不是各自最优化。id 与 slug 的别名问题现在说那个让我想了一会儿的设计。契约里文章详情是GET /articles/{idOrSlug}id 和 slug 都接受。这本来是件好事——URL 好看用户能收藏。但它带来了一个缓存问题同一篇文章有两个可能的键。用户在 App 里通过route进来看用的是 slug而从列表点进来用的是Article.route有 slug 用 slug没有用 id——看起来一致但只要有一个地方用了 id缓存就会分裂成两份。而且更麻烦的是用户可能先读 id 版本的缓存然后从 slug 版本进来界面上毫无区别但走了两次网络请求。现在的解法是一张别名表finalaliasesString,String{};// id 与 slug 恢复到同一正文键磁盘别名必须匹配当前 API 环境。FutureMapString,dynamicread(Stringid,{bool forcefalse})async{varcanonicalaliases[id]??id;if(!aliases.containsKey(id)!force){finalpersistedawaitcache.disk.findKey((key,meta)matchesAlias(key,meta,id),);if(persisted!null){canonicalCacheKey.tryParse(persisted)!.path.substring(CacheKeys.articlePrefix.length);}}finalkkey(CacheKeys.article(canonical),const{});拆开看这个逻辑内存里有别名直接用aliases是运行时攒的没有去磁盘上扫看有没有一个缓存键指向同一篇文章找到了就把它记进aliases之后都走它关键在第二步那个cache.disk.findKey。因为App 重启后内存别名就没了但磁盘缓存还在。这时候如果不做这个扫描用户重启后进文章又会重新请求一次。而matchesAlias(key, meta, id)那个判断里有一条关键约束——注释里写着磁盘别名必须匹配当前 API 环境。因为开发环境和线上环境的 baseUrl 不同如果把开发环境的 slug 别名带到线上环境会把两个环境的缓存搞混。别名表是内存 磁盘双层的而且磁盘那层要校验环境。这听起来像过度设计但它挡的是一类很难复现的 bug开发时缓存里全是别名切到线上环境后突然大量请求而且原因不明。别名表在什么时机失效看 M4-07 里那段this.cache.events.listen((e){if(e.kindremovede.key.contains(CacheKeys.articlePrefix)){articleReader.aliases.clear();this.cache.invalidate({articleLists});}});文章缓存被删除时别名表也清空。因为别名指向的正文已经不存在了再拿着旧别名去查会得到 404。目录面板让失效项变灰目录面板的实现比我想的有意思。核心是这一行enabled:keys[t.anchor]?.currentContext!null,onTap:(){Navigator.pop(c);finaltargetkeys[t.anchor]?.currentContext;if(target!null){Scrollable.ensureVisible(target,duration:MediaQuery.disableAnimationsOf(context)?Duration.zero:constDuration(milliseconds:250),alignment:.05,);}},enabled:这个属性做了一件很朴素但很有效的事锚点没匹配上的目录项灰掉、不可点。回到 M4-13 说的那个游标对齐算法——它是宁可漏也不错的原则所以理论上确实可能有标题拿不到 anchor。那么这些目录项该怎么表现可点但点了没反应 → 用户反复点以为 App 卡了灰掉不可点 → 用户知道这个跳不了但不会怪 App第二种明显更好而且它只需要一个判断。这也是 M4-13 那段宁可让某个标题点不动的下游兑现——那里的保守选择在这里变成了 UI 上可见的合理反馈。if (target ! null)那道检查是双保险enabled只影响点击回调的触发真正的执行时还要再确认一次。因为onTap触发到实际执行之间页面状态可能变了。而MediaQuery.disableAnimationsOf(context)这个判断值得单独说系统关闭动画时滚动动画也应该是 0 延时的。这是无障碍要求的实际应用。有些人因为前庭功能障碍在系统里关掉了动画App 里如果还播 250ms 的滚动他们会非常不适。而这个信息是可以从系统读到——不用猜也不用自己加开关。alignment: .05是滚动终点时目标标题离顶部的距离留一点空隙别把标题贴在状态栏下面。上下篇它不是上一页下一页上一篇和下一篇听起来简单。实现的时候发现一个定义问题上一篇是按发布时间倒数的上一条吗如果是那同一分类下的两篇文章之间可能根本没有上一篇——用户直接从一个分类跳进来没有上下文。所以现在的口径是用全文流里的相邻文章而不是分类内的相邻文章。用户读了一篇《接口设计》上一篇是《数据建模》即使这两篇分类完全不同——它们在时间流上是相邻的而接着读这个需求是连续的。这个决定的代价是上下篇可能和用户在分类页看到的顺序不一致。一个从前端分类进来的人看到的上一篇可能是篇 Node 后端文章。两种口径都有道理我选了全文流理由是它总能给出结果。分类口径下前后篇可能都为空那时候界面就是一个空区域用户得自己回去找导航。上下篇的数据来自adjacent这个资源家族ResourceFamily.adjacent:_ReadRule(CachePolicy(Duration(minutes:5),Duration(hours:1)),_Shape.object,),五分钟新鲜、一个小时过期不落盘。理由是它和正文是同一篇的上下文变化频率接近但独立请求一次没必要缓存太久。浏览量不能重复计但不能漏浏览量是写操作麻烦的地方在于去重和防刷这两个要求是对立的。不重复计用户反复进入同一篇文章不能每次都 1。防刷恶意脚本不能刷。后端 M1 已经做了防刷——去重 24 小时冷却。而客户端要配合的是同一个用户的同一次阅读只上报一次。关键判断是这个一次是按什么算的我最初的实现是进入文章页就上报。结果发现两个问题第一用户从列表点开文章、切到别的 Tab 又切回来——initState可能重跑上报两次。第二从上一篇跳到下一篇两篇文章各上报一次这个是对的但如果连点两次同一个链接就会报两次。现在改成绑定文章 id 的上报记录// 具体实现藏在文章页状态里规则是// 同一个 articleId 在本次 App 运行期间只上报一次这个本次 App 运行期间的限定词很重要。它意味着用户杀 App 重进再读一遍会再计一次 → 合理因为这是新的一次阅读用户反复进出同一篇 → 只计一次 → 合理用户换设备读 → 会再计一次 → 接受这个不精确为什么不做得更精确精确意味着要记录用户在什么位置读到了百分之多少数据量大了很多而对内容产品来说收益很小。浏览量的误差容忍度很高但它必须有一个下界——不能是 0。浏览量的上报失败不提示用户。这是个后台行为失败了用户也无从补救弹个错误只会让用户困惑。这个判断看起来有点武断但它体现了一个原则错误的可见性应该和用户的处置能力匹配。阅读历史本地优先同步兜底最后一个也是唯一一个真正需要处理离线的产品功能。阅读历史的需求有三个跨设备能看到、离线也能看、不重复记录同一篇文章。现在的策略是本机存储 登录后同步场景数据来源登录用户在线服务端权威登录用户离线本机最近同步的未登录只有本机服务端那份是权威的因为它跨设备。本机那份是缓存让离线可用。麻烦点在写入。用户读到第 5 页就退出了——这篇文章算读过吗现在的口径是停留超过 20 秒且滚动超过一屏才记入历史。这两个条件缺一不可。只有停留时间的话一个手滑点进来立刻退出的也算只有滚动的话用户可能停在第一屏没往下看那显然不算读完。20 秒和一屏这两个具体数字是可调的但必须有阈值。一个打开就记的阅读历史毫无价值——它会把用户所有点过的文章都记上包括那些他根本不想看的。而本机那份要按账号隔离// 会话隔离清理不影响公开资源也不触及按账号保存的投稿草稿。voidresetPrivate(){...favoriteIndex.resetFavoriteIndex();reactions.clear();...cache.invalidate({private});M4-09 讲过这个resetPrivate。这里体现出来的是用户 A 退出登录后A 的阅读历史不能被用户 B 看到。如果阅读历史没做账号隔离那就是一个实实在在的隐私问题——同一台手机上切换账号另一个人能看到你读过什么。而这在共享设备比如家人的平板上很容易发生。这一页的四组缓存策略把它们放一起看这篇文章其实没什么业务逻辑全是策略功能资源家族freshmaxAge落盘私有正文 目录article10 分钟24 小时是否上下篇adjacent5 分钟1 小时否否浏览量上报article————阅读历史history30 秒5 分钟否是四个决策点每个都有明确的理由正文落盘、上下篇不落盘。正文是用户反复要看的而且断了网络也要能读上下篇是顺带功能离线时看不到无所谓省下的磁盘空间更值得。上下篇比正文新鲜期长。因为它的数据来自同一篇文章的关联查询服务端不需要额外计算而它又不常变。阅读历史不落盘。私有数据落盘意味着要处理用户卸载前的数据会不会残留成本高于收益。浏览量不走缓存。它是写请求每次都要真发。一条我一开始想省掉的事阅读历史的停留 20 秒 滚动一屏我第一版只做了滚动检测。理由是滚动说明用户真的在看比停留时间靠谱。结果用户反馈说有些文章他就想快速扫一遍要点看到了就退这种也被记进了历史。这个反馈让我意识到停留时间不该被当成次要指标。快速扫读是真实的阅读行为它和点错了立刻返回在滚动数据上可能很像都没滚很远但在时间上差很多。于是两个条件变成并列关系。而加了时间条件之后的副作用是用户网络不好、文章加载慢的那 20 秒里可能什么都没看到但仍会被计入。这个副作用我知道存在。现在的判断是这类误差和收益不成比例——误记一次阅读的成本远低于漏记一次的成本。同样的权衡在浏览量上也做了宁可能多计一次不能少计。这两处的共同点是浏览类数据允许有误差但误差的方向必须是偏高。因为偏低的数据会让人觉得我的文章没人看那是真实的问题偏高只是统计不精确没人因此受损。小结文章页看起来是全项目最简单的一页——它没有表单、没有列表、没有并发写。但实际做完发现四个小功能里有三个的难度不在实现在定义目录和正文必须同版本—— 两个有语义依赖的数据缓存生命周期取交集。id/slug 别名要跨重启—— 内存别名 磁盘扫描且校验 API 环境。失效的目录项要变灰—— 下游的保守选择需要在上游变成可见的合理反馈。阅读要有阈值—— 停留 滚动且接受偏高的误差方向。第二个和第三个尤其值得记。id/slug 别名那个问题在测试环境几乎发现不了因为测试每次都用同一个 key 进而目录项变灰的价值只有真的发生过锚点失配的人才知道。还有一条是贯穿全系列的无障碍不是附加项。目录滚动动画读系统设置M4-14M4-26 讲测试时会看到可访问性的自动化检查。它们都是同一个思路——能用系统提供的信息推断的就不要自己猜。下一篇讲 WebView。它和前面所有页面都不同因为它要在 App 里放一个浏览器。延伸阅读Flutter Markdown 阅读器支持范围与渲染边界Dio Repository统一响应信封与模型适配文章阅读辅助上下篇、面包屑、相关文章与阅读记录如果这篇文章对你有帮助欢迎订阅我的 CSDN 专栏「成为全栈」 专栏地址https://blog.csdn.net/fungleo/category_13204651.html 本系列配套代码仓库https://github.com/fengcms/become-a-full-stack-developer