双列瀑布流是这类产品的门面,我以为最难的部分是「怎么把高度不一的卡片排整齐」。算法本身半天就写完了,真正花时间的是四个我完全没预料到的问题——而它们在我的电脑上一个都不出现。
一是图片位置一直在跳。我的卡片高度是由图片撑开的,而图片的高度只有下载完成之后才知道。结果是:列表先渲染出一堆没有高度的空卡片,然后图片陆续加载完,每加载完一张就把它下面的所有内容往下顶一次。用户正在看的内容会反复被顶走,而且图片越多、网络越慢,跳动持续得越久。我在电脑上因为图片加载得太快,几乎看不出这个问题;换到限速环境下,整个列表跳了好几秒。
二是两列长度差距很大。我最初的分列是「第一张放左边、第二张放右边、第三张放左边」这样交替。但图片高度差异很大,交替放的结果是某一列全是长图、另一列全是短图,两列底部能差出一屏多。
三是列表直接加载原图,流量和内存都撑不住。我造的测试图片是手机原图,单张两三兆。首屏 10 张卡片就是二三十兆的下载量——在限速环境下首屏要等很久;而在旧安卓机上多滑几屏就明显卡顿,因为几十张大图同时在内存里。
四是不回收节点,滑到几百条就卡死。我用 v-for 直接渲染全部数据,节点数随滑动线性增长。在旧安卓机上滑到几百条时已经滑不动了。
所以四个改动:上传时解析并存储图片宽高、列表接口返回、前端按宽高比预留占位、分列改为放入当前较短的那一列、上传时生成缩略图,列表用缩略图详情用大图、加视口外节点回收。
这个模块最想说的一句话是:前端之所以不知道图片高度,是因为这个信息在上传的那一刻就丢掉了——而它本来是知道的。我一直在前端想办法(先加载一遍拿尺寸、用固定比例猜),全都是在补救;真正的解法是在上传时把宽高存下来。想通这一点之后我去翻了几个主流图文社区的列表接口,发现它们返回的图片信息里都带着宽和高——原来这就是它们的图片位置从来不跳的原因。
把「获取图片尺寸」从前端挪到上传时的服务端,这是我在这个项目里做过的最有价值的一个判断。前端的几种办法我都试过:先用一个隐藏的图片元素加载一遍拿尺寸(等于加载两次,更慢)、用固定比例猜(猜错了照样跳)、只在首屏用固定高度(滑下去还是跳)——全都是在补救,因为信息本身不在前端手里。而服务端在接收上传的那一刻就能拿到宽高,存一下几乎零成本。判据是「这个信息最早在哪一步是已知的」:在上传时已知,那就在那时候存下来,不要等到用的时候再想办法算。
列表用缩略图而不是统一用原图,代价是要多存一份文件。统一用原图省掉生成和存储。但它同时带来三个问题:下载量大、内存占用高、首屏慢,而列表上的图只有一列宽,原图的分辨率完全是浪费。判据是「展示尺寸和原始尺寸差多少」:差一个数量级,那就必须分级。缩略图的生成放在上传时(一次)而不是请求时(每次),这一点也很重要。
选固定宽高比的瀑布流而不是等高网格。等高网格实现最简单、高度天然确定、回收也好做。但瀑布流是这类产品的视觉特征,改成等高网格就不像了;而且等高会把长图裁得只剩中间一条。代价是要处理分列均衡和高度计算——而在「上传时存宽高」之后,这两件事都变得很简单了。
踩过的坑一:图片位置跳动,而我在电脑上几乎看不出来。因为图片加载得太快。我是把开发者工具的带宽限到几十 KB 才看清整个过程的——列表跳了好几秒。教训是:涉及「资源加载过程」的体验问题,必须在慢网络下看,因为快网络会把过程压缩到你看不见。我现在做前端会习惯性地先限速跑一遍。
踩过的坑二:我在前端花了很多时间想办法拿图片尺寸,方向从一开始就错了。我试过隐藏元素预加载、试过固定比例。后来才想到:服务端在上传时本来就知道宽高,是我没存。教训是:遇到「这个信息我拿不到」的问题时,先往上游追一下——它在哪一步是已知的?很多时候不是拿不到,是在某一步被丢掉了。
踩过的坑三:交替分列导致两列差一屏多。这个我在电脑上是能看到的,但因为我最初造的测试图片比例比较接近,差距不明显;后来我故意造了一批极端比例的图(超长图、近正方形混在一起),问题立刻放大。教训是:造测试数据要故意造极端值,用「正常」的数据测不出边界。
没做的部分:没做图片的渐进式加载(先出一张极小的模糊图再替换成清晰图)。它能让首屏观感更好,但需要再生成一档极小图、还要处理两次加载的切换,而我已经有缩略图这一档、首屏时间在限速下也能接受了。取舍依据是「收益是观感上的,而当前的主要矛盾(跳动和流量)已经解决」——先解决正确性和量级问题,观感优化排后面。
布局跳动要报「布局偏移」并说清测试条件:用开发者工具把带宽限到几十 KB,录制首屏加载过程、统计图片加载引起的累计位移。报法是「限速到 X 的条件下,改造前首屏加载过程中出现持续数秒的位移,改造后位移接近零」——必须带上限速条件,因为不限速这个数字本来就接近零。
首屏图片下载量是这个模块最直观的数字:报「首屏 N 张卡片的图片总下载量从 X 兆降到 Y」,并说明缩略图是按列宽的多少倍出的。这个数字很容易核对。
滑动帧率必须报机型和条数:「在某型旧安卓机上,加载到 N 条时滑动帧率从 X 提升到 Y」。只报帧率不报机型和条数没有参考价值——而且要说明是用性能面板录制滑动过程取的。
节点数比帧率更本质:报「加载到 N 条时页面节点数,改造前随条数线性增长、改造后基本恒定」。这个对比解释了为什么帧率改善了。
分列均衡用断言型用例:造一批极端比例的图片(超长图与近正方形混合),断言两列累计高度差在一屏以内。要说明测试图片是故意造成极端比例的,因为用正常比例的图测不出问题。
宽高兜底:造几条宽高为零或极端比例的数据,断言前端不会算出零高度或撑满整屏。
历史数据补齐:断言回扫脚本跑完后,所有内容都带宽高,老内容也不再跳动。
不要报什么:不要报「首屏加载速度提升 N 倍」——它完全取决于限速设成多少。该报的是「限速到某个值时改造前后的位移对比」「首屏图片下载量的绝对值对比」「某机型某条数下的帧率与节点数」「极端比例图片下两列高度差在一屏内」这几件带条件的、可核对的事。带条件恰恰说明你知道自己在测什么。
发布页我一开始按最直觉的顺序做:选图 → 填文案 → 点发布 → 上传图片 → 创建内容 → 跳转。在我的电脑和好网络下这个流程一气呵成,几乎察觉不到上传的存在。把上行带宽限到几十 KB 之后,四个问题全出来了。
一是点了发布之后要等很久,而且失败率很高。九张图、每张两三兆,在限速条件下要传好几分钟。而这段时间用户只能盯着一个进度条什么都做不了——他刚才填文案的那一两分钟完全被浪费了。更糟的是传到第七张失败,整个发布就失败了。
二是我改成并发上传之后,反而更慢了。我把九张图全部同时发出去,结果它们互相抢上行带宽,每一张都传得很慢,总时间没有明显改善,而且失败率更高了(因为每个连接都长时间处于低速状态)。
三是失败之后要整套重来。我的实现是「上传全部图片 → 拿到全部地址 → 调创建内容接口」。任何一张失败就重新走一遍,前面成功的八张白传了。更蠢的是有一次图片全传完了、创建内容的接口失败了(我的参数校验有 bug),重试的时候九张图又全传了一遍。
四是编辑中断内容全丢。我在安卓机上测的时候,切到后台去查个东西,回来发现小程序被系统回收了,填的文案和选的图全没了。而我刚才等了好几分钟才把图传完。
所以四个改动:选图后立即在后台开始上传、限制并发数而不是全并发、单张失败只重传该张、已成功的图片凭据缓存复用、自动保存本地草稿(含已上传成功的图片凭据),另外补了客户端压缩。
这个模块最想说的一句话是:用户点「发布」的那一刻才开始上传,是把一段本来可以并行的等待硬做成了串行。他填文案的那一两分钟里网络完全是空闲的。把上传提前到「选完图」这一刻,等待时间没有变短,但它被藏进了用户本来就要花的时间里。想通之后我去看了几个主流图文社区的发布页,发现它们在你选完图返回编辑页时,图片旁边已经有进度了——原来那不是装饰,是真的在传。
把上传提前到「选完图」,这是这个模块收益最大的改动,而它几乎不增加代码复杂度。代价是用户可能选完图又放弃发布,那些图就白传了(服务器上多了没被引用的文件)。我的处理是给这类文件加一个定时清理——上传超过一定时间且没有被任何内容引用的就删掉。判据是「白传几张图的成本」和「点发布后干等几分钟的成本」哪个大:后者大得多,而且后者会直接导致放弃发布。
限并发而不是全并发,这一点和直觉相反。我最初以为「并发越多越快」,实测发现九张全并发时它们互相抢上行带宽,每个连接都长时间处于低速状态,总时间没有明显改善、失败率反而更高。判据是「瓶颈是不是共享资源」:上行带宽是共享的、总量固定,那么增加并发只是把同一份带宽切得更碎,并不会变快;而连接数增加本身还有额外开销。并发数的具体取值我是在限速条件下逐个试出来的,而不是拍一个数。
客户端压缩而不是让服务端处理大图。服务端压缩不损失原始画质、前端更简单,但它解决不了核心问题——上传本身就慢、就容易失败。问题出在传输环节,就必须在传输之前解决。代价是原图不再保留、以及压缩参数要拿准。压缩参数我的验收标准是「放大看不出明显涂抹」,在真实照片上试出来的——因为图文社区的内容就是图,压坏了整个功能就没意义了。
草稿存本地而不是服务端。存服务端能跨设备恢复。但未发布的内容存到服务端要处理清理、隐私和额外的接口;而这个场景的实际需求是「刚才被打断了、现在回来继续」,基本都在同一台设备上。判据是「跨设备的需求有多强」:很弱,那就用最简单的方案。
踩过的坑一:图全传完之后创建内容的接口失败,重试时九张图又全传了一遍。失败原因还是我自己的参数校验 bug。我当时看着进度条重新从零开始,特别难受。教训是:一个多步骤流程里,已经完成的昂贵步骤必须能被后续重试复用——而「昂贵」在这里指的是耗时和流量,不是计算量。修法很简单:把已成功的图片凭据缓存起来,重试时先看有没有。
踩过的坑二:以为并发越多越快,结果更慢。我是把限速开着、分别试串行、限并发、全并发三种,看总耗时和失败率才发现的。教训是:并发的收益依赖「瓶颈是不是可以并行」——上行带宽不能,所以增加并发只是把带宽切碎。这一条我之前只在书上见过,自己实测一遍之后才真的信。
踩过的坑三:切后台被回收,文案和图全丢。而我刚才等了几分钟才把图传完。教训是:移动端要假设「页面随时会被系统回收」——这不是异常情况,是安卓上很常见的正常行为。而且草稿里必须存已上传成功的凭据,只存文案的话恢复后还要重传,等于没救回来多少。
没做的部分:没做大文件分片续传。压缩之后单张图只有几百 KB,分片续传的收益很小而复杂度不低(要处理分片编号、合并、断点记录)。取舍依据是「压缩已经把问题缩小到不需要续传的程度」——先用便宜的办法把问题规模压下来,往往比直接上复杂方案划算。
全部数字必须带限速条件,这是这个模块的前提:我报的是「把上行带宽限到几十 KB 每秒」。不限速的话所有对比都接近零差异——因为好网络下九张原图也就几秒。
压缩效果报三个数:原图体积、压缩后体积、以及画质是否可接受(放大看不出明显涂抹)。只报压缩比不报画质,等于没说清这个优化有没有副作用。
「感知等待」是这个模块最有说服力的指标:报「点击发布后仍需等待的时间,从『九张图的完整上传时间』降到『剩余未完成图片的时间』,多数情况下接近零」。要说明这不是上传变快了,而是等待被前置到了用户填文案的时间里——诚实说明这一点比夸大更可信。
并发数的选择要报实测过程:「在限速条件下分别试串行、限并发、全并发,记录总耗时与失败率,取总耗时与失败率都较优的并发数」。直接说一个数字而不说怎么来的,就是拍的。
单张重传:断言让第三张失败时,前两张保持成功状态、只有第三张显示失败与重传、重传后不影响其他张。
凭据复用是踩坑的直接回归:让创建内容接口失败,断言重试时不重新上传任何图片(可以看网络请求数为零)。
草稿恢复:填一半、选好图、等图传完,然后杀掉进程重进,断言文案与已上传图片都恢复、且不需要重新上传。后半句最容易漏测。
提交幂等:弱网下连续点两次发布,断言只产生一条内容。
不要报什么:不要报「上传成功率 100%」——弱网下不可能。该报的是「限速到某个值时压缩前后的体积与画质结论」「点发布后的剩余等待时间对比」「三种并发策略的实测耗时与失败率」「创建内容失败后重试不重传图片」这几件带条件的、可核对的事。
这个模块解决的都是「页面之间」的问题,而它们的共同特点是:单看任何一个页面都完全正常,只有在页面之间来回走才会暴露。四个问题。
一是详情页点了赞,返回列表还是没赞。我的列表和详情各自请求各自的数据,两份数据互不相干。用户在详情页点了赞、返回列表,卡片上的心还是空的、数字也没变——他会以为刚才没点上,再点一次就变成取消了。这个问题在单个页面里怎么测都不会出现。
二是返回列表回到顶部。我从列表进详情,返回时列表重新请求、重新渲染、滚动位置归零。用户在瀑布流里滑了几十屏点进一条内容,返回时要重新滑回去——这是我自己用的时候最烦躁的一点。
三是我加了滚动位置恢复之后,位置一直是 0。我在页面显示时读出缓存的滚动值直接设置上去,但列表数据还没渲染完、页面根本没有那个高度,滚动值被钳制回了 0。我查了很久才明白是顺序问题——必须等列表渲染出高度之后再设置。
四是详情页打开时把九张图全部预加载了。我以为「提前加载体验更好」,结果是九张图同时请求、互相抢带宽,连第一张都出得很慢。而用户往往只看前两三张。
所以四个改动:用全局状态层集中保存被改动过的内容状态、缓存列表数据与滚动位置并在返回时恢复、修正恢复顺序(先渲染再设置滚动)、轮播图只预加载相邻的前后各一张。
这个模块最想说的一句话是:只要同一份数据会在两个页面上出现,它就不能由两个页面各自持有——必须有一个共同的来源。我最初的写法是每个页面自己请求自己的数据,这在单页面视角下完全合理,但它意味着「同一条内容的点赞状态」在系统里有两份,而两份就一定会不一致。这一条和后端「同一份数据不要有两条来源」是同一个道理,只是发生在前端页面之间。
用「只保存被改动过的状态」而不是「缓存全部内容数据」。全量缓存能让所有页面共享一份数据、彻底消除不一致。但它要处理缓存过期、内存上限、以及「什么时候该用缓存什么时候该用接口」这些问题,复杂度明显上升。而我实际要解决的问题很窄:只是互动状态需要跨页同步——内容本身(图文、作者)是不会变的。判据是「真正会在页面间变化的字段有哪些」:只有互动状态,那就只管它。这个范围收窄让整个实现简单了很多。
全局状态层不做持久化,只在本次会话有效。持久化能让冷启动后也保持状态。但那会引入一个更麻烦的问题:本地状态可能比服务端旧(比如我在另一台设备上取消了赞),而本地又以自己为准,就会长期显示错的。判据是「本地状态和服务端冲突时以谁为准」:应该以服务端为准,那本地就不该长期持有。
返回时用缓存渲染而不是重新请求。重新请求能拿到最新数据。但它有两个问题:位置无法恢复(数据还没回来)、而且刷新后条目可能变了导致位置对不上。用缓存的代价是可能显示稍旧的数据——我认为这个代价可以接受,因为用户刚从详情页返回,他的预期是「回到我刚才看的地方」而不是「看到最新内容」。
轮播只预加载相邻的,而不是全部。全部预加载在好网络下确实体验更好。但在限速条件下它是负优化——九张图抢带宽,连用户想看的第一张都出得慢。判据是「预加载抢占的资源会不会影响当前要用的东西」:会,那就必须限制范围。预加载的边界通常是「用户下一步最可能要用的那一点点」,而不是「所有可能用到的」。
踩过的坑一:详情页的互动结果不回传列表。用户以为没点上、再点一次就取消了。我最初的写法是每个页面自己请求自己的数据,这在单页面视角下完全合理——但它意味着「同一条内容的点赞状态」在系统里有两份,而两份就一定会不一致。教训是:只要同一份数据会在两个页面上出现,它就不能由两个页面各自持有,必须有一个共同的来源。这一条和后端「同一份数据不要有两条来源」是同一个道理。
踩过的坑二:设置滚动位置一直无效,值总是 0。我一开始怀疑是缓存没存对、怀疑是取值时机不对,查了很久才明白是列表还没渲染出高度、滚动值被钳制了。教训是:设置滚动位置、测量元素尺寸这类操作,都必须等布局完成之后做——而「布局完成」的时机不等于「数据赋值完成」。
踩过的坑三:以为预加载越多越好。详情页打开就把九张图全请求了,结果连第一张都很慢。教训是:预加载是在和「当前正在用的资源」抢带宽——它不是免费的,范围必须受限。
没做的部分:没做列表数据的本地持久化(冷启动直接显示上次的内容)。它能让冷启动更快看到内容,但要处理数据过期、以及「显示的内容点进去已经被删了」这类情况。取舍依据是「冷启动的频率远低于页面间跳转」——先把高频路径做好。
跨页状态一致性用断言型用例,这是最核心的一条:在列表进入详情、点赞、返回,断言列表卡片上的点赞状态与数字已同步;再进入该内容的详情,断言状态仍然一致。第二步容易漏,但它能验证全局状态层真的生效而不是碰巧刷新了。
失败回滚:让点赞请求失败,断言界面回滚为未点赞状态并给出提示,而不是停在「已赞」的假成功。
返回态恢复要在真机上测并报机型:滑到某个位置、进详情、返回,断言滚动位置与离开时一致(允许小误差)、列表数据未重新加载。要说明是在哪台机器上测的——不同机型的渲染时机有差异。
恢复顺序是踩坑的直接回归:断言「先渲染列表、再设置滚动位置」的顺序下位置生效;可以再写一条反向用例说明颠倒顺序时位置会变成 0——保留这条反例,改动代码时能立刻发现回归。
静默刷新不跳位置:返回后触发静默刷新,断言滚动位置不发生二次变化。
轮播预加载要报请求数:「打开详情页时发起的图片请求数从 9 降到 2(当前图 + 相邻 1 张)」。这个数字很直观,而且首图出图时间的对比要带限速条件。
首屏过渡:断言从列表进详情时先显示列表已加载的缩略图、大图加载完成后替换,过程中不出现空白。
不要报什么:不要报「体验提升」这类无法度量的说法。该报的是「详情页互动后列表状态同步(含二次进入仍一致)」「返回后滚动位置一致且数据未重新加载」「详情页图片请求数从 9 降到 2」「请求失败后界面正确回滚」这几件可核对的事。
请求层是我最早写、也是最久没有动过的一块——因为它「能用」。直到我把令牌的有效期改短了几分钟做测试,四个问题一起冒出来。
一是令牌过期就把用户踢回登录页,体验完全断裂。我的拦截器逻辑是「收到未授权的响应就跳登录页」。结果是用户正在写一条内容、选好了九张图,令牌过期了——直接跳走,内容全丢。而令牌过期是一个纯技术细节,用户完全不知道自己做错了什么。
二是我加了静默续期之后,出现了更奇怪的问题:连环失败。页面首屏会并发发好几个请求。令牌刚过期时,这几个请求几乎同时收到未授权,于是各自都去发起了一次续期——而服务端每次续期都会让上一个令牌失效,所以后完成的那次续期把先前那次换来的令牌作废了。表现是有的请求成功、有的失败,刷新一下又好了,非常难查。
三是登录成功后总是回首页。用户点了一条内容的分享链接,未登录被跳到登录页,登录成功之后回到了首页——他要重新去找那条内容。而他本来的意图是看那条内容。
四是退出登录之后还能看到数据。我在各个页面里直接读写本地存储的令牌。退出登录时我清理了主要的几处,但漏了一个页面自己缓存的副本——那个页面在退出之后依然能正常请求。
所以四个改动:令牌过期改为静默续期并重放原请求、并发失效时合并成一次续期、未登录跳转记录来源、登录后回到原页面、令牌读写收敛到唯一入口。
这个模块最想说的一句话是:请求层是所有页面共用的那一层,所以它的问题不会只出现在一个地方,而是「到处都有一点」。我一开始把令牌读写散在各个页面里,看起来每处都很简单,但退出登录时我必须记得清理全部这些地方——而我漏了一个。把它收敛成唯一入口之后,退出登录只需要改一个地方,而且不可能漏。
令牌过期做静默续期而不是直接跳登录页,代价是请求层复杂度明显上升。直接跳登录页只有一行代码。但它把一个纯技术细节(令牌到期)变成了用户的损失(正在写的内容全丢)——而用户完全不知道自己做错了什么。判据是「这个失败用户有没有能力预防」:没有能力(他无法知道令牌何时过期),那就应该由系统自己消化掉,而不是让他承担后果。而续期带来的复杂度(合并、重放、次数上限)是这个体验的必要成本。
续期合并选「第一个负责、其余挂起」而不是「每个请求各自续期」。各自续期实现最简单(不需要任何协调)。但服务端每次续期都会让上一个令牌失效——这是一个安全上合理的设计(防止续期凭据被重复利用),可它意味着并发续期必然互相踩踏。合并的代价是要维护「正在续期」这个状态和一个等待队列。判据是「这个操作能不能被并发执行」:不能(因为它有副作用且会互相作废),那就必须在客户端侧串行化。这一条和后端「同一个键的加载只执行一次」本质上是同一个模式。
令牌读写收敛到唯一入口,这是我做完之后觉得最应该早点做的一件事。散在各页面时每一处都很简单、改起来也快。但它有一个隐藏成本:任何涉及「所有令牌使用方」的改动(退出登录清理、加静默续期、换存储方式)都要改很多处,而且必然会漏。收敛之后退出登录只需要改一个地方,而且不可能漏。判据是「以后会不会有需要一次性改动全部使用方的需求」:会(而且已经发生了两次),那就该收敛。
读请求可以自动重试、写请求绝对不自动重试。统一都重试能提高成功率。但写请求自动重试会产生重复数据——除非它带幂等键,而我不能假设所有写接口都带。所以请求层的默认策略是「读重试、写不重试」,需要重试的写接口由业务显式声明并保证幂等。判据是「重复执行有没有副作用」:有,那默认就不能重试。
踩过的坑一:令牌过期直接跳登录页,用户写了一半的内容全丢。我是把令牌有效期改短做测试时撞到的,那一刻我意识到这个设计对用户是很粗暴的。教训是:区分「用户的错」和「系统的技术细节」——后者不应该让用户付代价。
踩过的坑二:并发续期导致连环失败,这是我在这个项目里查得最久的问题之一。现象很诡异:首屏有的请求成功、有的失败,刷新一下又好了。我怀疑过接口不稳定、怀疑过超时设置。后来打日志才看到几个请求各自发起了续期,而服务端每次续期都会让上一个令牌失效,所以它们互相把对方的令牌作废了。教训是:「有副作用且会互相作废」的操作绝对不能被并发执行,即使调用它的地方是天然并发的(首屏多个请求)——必须在客户端侧主动串行化。
踩过的坑三:退出登录之后有个页面还能正常请求。因为它自己缓存了一份令牌副本,而我清理时漏了它。教训是:同一份敏感状态在系统里存了几份,就有几个地方需要在清理时被记得——而「记得」是不可靠的。正确做法是让它只有一份。
没做的部分:没做令牌的自动预续期(在过期前主动换新,让请求永远不会遇到失效)。它能进一步消除续期时的那一次延迟,但需要在客户端维护一个准确的过期倒计时,而客户端时间不可信、应用还会被挂起——倒计时不准反而会造成不必要的续期。取舍依据是「被动续期已经把体验做到了无感知,主动预续期的增量收益很小、而它依赖一个不可靠的前提」。
令牌失效的测法要说清怎么构造:我是把本地存的令牌手动改成一个过期值(或把服务端的有效期改短到几分钟),这样可以稳定复现。报法要带上这个构造方式——否则「令牌过期」这个场景听起来无法验证。
静默续期是断言型用例:令牌失效状态下发起一个请求,断言用户没有被跳转到登录页、请求最终成功返回、且期间只发生了一次续期。三条都要断言,第三条是重点。
并发合并是踩坑的直接回归,也是这个模块最有价值的一条:令牌失效状态下并发发起 N 个请求,断言续期接口只被调用 1 次(可以在网络面板里数)、N 个请求全部成功、没有任何一个因令牌被作废而失败。报法是「N 个并发请求下续期调用次数从 N 降到 1」。
续期失败的路径:让续期接口也返回失败,断言全部挂起的请求一起失败、然后跳转登录页、且只跳一次。
重放次数上限:让接口在续期成功后仍返回未授权,断言重放达到上限后停止,不会无限循环。
回跳:从一条内容详情页触发未登录跳转,登录成功后断言回到该详情页且参数完整;断言传入站外地址时回跳被拒绝。
退出登录的清理完整性:退出后逐个页面断言无法再请求成功、界面上无残留数据、长连接已断开。「逐个页面」是关键——我漏的那个坑正是只测了主要页面。
加载态:断言请求结束(无论成功失败)后加载态一定被关闭,包括超时和被拦截的情况。
不要报什么:不要说「登录体验提升」——无法度量。该报的是「构造令牌失效的方式」「N 个并发请求下续期只调用 1 次」「续期失败时只跳一次登录页」「重放有上限不会无限循环」「退出后逐页面均无法请求成功」这几件可核对的事。
首屏速度是我最后才认真处理的一块——因为在我的电脑和好网络下,打开首页几乎是瞬间的。把下行限到几百 KB、再换到旧安卓机上,四个问题一起暴露。
一是首屏的几个请求是串行发的,耗时直接相加。我的写法很自然:先校验登录态、拿到用户信息、再去请求列表数据——每一步都等上一步返回。限速之后这三次往返叠加起来,首屏要等好几秒。而实际上其中两个请求之间并没有真正的依赖关系,完全可以同时发。
二是主包塞了全部页面,接近平台体积上限而且启动变慢。我把所有页面和依赖都打在一起。其中「图片编辑」这个页面引入了一个比较重的依赖,而它只在发布流程里被用到——但它被打进了主包,导致每个用户打开首页都要先下载它。
三是加载态是一个全屏转圈,让人觉得更慢。限速下这个圈要转好几秒。而用户看到转圈时,除了「在加载」什么信息都没有——他不知道要等多久、也不知道会出现什么。
四是我做的图片预加载在首屏阶段是负优化。首页一打开就开始预加载后面几屏的图片,而这些预加载请求和首屏那几张图抢同一份带宽——结果是首屏图片出得更慢了。这个问题和我在播放流那边遇到的完全同源:预加载不是免费的。
所以四个改动:能并行的请求并行、不阻塞渲染的延后、按功能分包并移除主包里的重依赖、全屏转圈改为与真实结构一致的骨架屏、预加载等首屏图片就绪后再启动。
这个模块最想说的一句话是:首屏慢的原因往往不是「某一步慢」,而是「本来可以同时做的事情被排成了一队」。我最初的三个串行请求里,只有一处是真依赖(要先知道是谁才能拿他的信息),另一处纯粹是我按书写顺序排下来的。把它拆开之后首屏时间下降的幅度,比我优化任何单个接口都大——而这个改动几乎不涉及任何新技术。
请求并行化是这个模块里收益最大、成本最低的一个改动,而它不涉及任何新技术。我最初的串行顺序不是设计出来的,是我按书写顺序自然排下来的——先校验登录、再拿用户信息、再拿列表。逐个检查依赖关系之后发现「请求列表」根本不依赖「用户信息」,它只需要令牌。判据很简单:这个请求真的需要上一个请求的结果吗——大多数时候答案是不需要。我特意强调这一点,因为它说明首屏慢往往不是「某一步慢」,而是「本来可以同时做的事被排成了一队」。
分包的代价是「首次进入某个页面会多一次加载」,我用「提前预载」来缓解而不是取消分包。不分包的话所有页面都是即时的。但代价是每个用户打开首页都要先下载他这次可能根本不会用到的东西——而首屏是每个用户必经的,发布器只有一部分用户会用。判据是「这段代码有多大比例的用户会用到」:比例低的就该延后加载。缓解手段的关键是「在用户表现出意图的那一刻」预载(点了发布按钮而不是打开首页时),这样既不占首屏带宽、又几乎没有等待。
骨架屏而不是转圈,代价是每个页面要多画一份骨架结构。转圈是一行代码、通用、不用维护。但它传递的信息量是零——用户不知道要等多久、会出现什么;而骨架屏让他提前知道「这里会出现一个双列瀑布流」,感知等待时间明显更短。代价是骨架结构要跟着真实结构一起维护(改了布局要记得改骨架)。判据是「等待时间有多长」:限速下要等好几秒,那就值得为它做骨架;如果只等几十毫秒,转圈甚至什么都不做反而更好。
预加载让位于首屏图片,而不是同时进行。同时进行看起来「更充分利用带宽」。但带宽是共享且总量固定的——预加载抢走的正是首屏那几张图需要的部分。判据是「这个提前准备抢的是谁的资源」:抢的是当前正要用的资源,那就必须让位。这一条和我在播放流那边的预缓冲问题完全同源,同一个错误我犯了两次,所以后来把它写成了一条自检。
踩过的坑一:三个首屏请求串行,我完全没意识到它们可以并行。因为代码读起来非常顺(一步接一步)。是限速之后看请求瀑布图才发现三个请求整整齐齐地排成了一列。教训是:代码的书写顺序会不自觉地变成执行顺序,而它们之间往往并没有真正的依赖——看请求瀑布图是发现这类问题最快的方式,比读代码快得多。
踩过的坑二:一个只在发布流程用到的重依赖被打进了主包。我是在查主包体积构成时发现的。教训是:依赖的「引入位置」决定了它被打到哪里,而这一点在写代码时完全感觉不到——必须专门去看体积构成,否则主包会不知不觉地长大。
踩过的坑三:预加载在首屏阶段是负优化,而且这是我第二次犯同一个错误。第一次在播放流的预缓冲上,第二次在这里。教训是:如果同一类错误犯了两次,说明它不是疏忽而是认知缺口——所以我把「预加载抢的是谁的资源」写成了一条固定自检,每次做提前加载都过一遍。
没做的部分:没做首屏数据的服务端聚合(把几个首屏请求合并成一个接口)。它能把并行的几次往返进一步压成一次,收益是实在的;但代价是后端多一个只服务于这个页面的聚合接口,页面改动时它也要跟着改。取舍依据是「并行化之后首屏时间已经可以接受,而聚合接口会把前后端耦合得更紧」——不过我能说清它是下一步,以及它的代价在哪。
所有数字必须带限速条件,这是这个模块的前提:我报的是「把下行限到几百 KB 每秒」,并在旧安卓机上验证启动耗时。不限速的话所有对比都接近零差异——在我的电脑上首页几乎是瞬间的。
请求并行化的效果最好用请求瀑布图说明:报「首屏请求从三次往返串行叠加,变为一次依赖串行加两次并行」,并报首屏数据就绪时间从 X 降到 Y。瀑布图的形状(一列变成一个台阶)比数字更直观,而这也是我发现问题的方式。
包体积要报主包体积的绝对值与构成:「主包体积从 X 降到 Y,其中移出的最大一项是某个只在发布流程用到的依赖,约占 Z」。报出「最大的一项是什么」比只报总量有用得多,因为它说明我看过体积构成。
启动耗时要报机型:「在某型旧安卓机上,从点击图标到首屏骨架出现的时间从 X 降到 Y」。要区分「骨架出现」和「数据就绪」两个时间点——前者主要受包体积影响、后者主要受请求影响,混成一个数字就说不清是哪个改动起的作用。
骨架屏的效果不适合用技术指标衡量,可以报「首个有意义的画面出现时间」:骨架出现的时间远早于数据就绪的时间,这个差值就是骨架屏填补的那段等待。诚实说明这不是「变快了」,而是「等待期间有了内容」。
预加载让位的效果用对照实测:「限速条件下,预加载与首屏图片同时启动 vs 首屏就绪后再启动,两种策略下首屏图片全部出图的时间分别是 X 和 Y」。报出「同时启动反而更慢」这个反直觉结果。
分包预载:断言点击「发布」按钮时开始预载分包,进入发布页时无额外等待;并断言不点击时不会加载该分包(可以看网络请求确认)。
不要报什么:不要报「首屏速度提升 N 倍」——倍数完全取决于限速设成多少。该报的是「限速值与机型」「首屏请求从串行叠加变为一次串行加两次并行及数据就绪时间对比」「主包体积绝对值与移出的最大一项」「骨架出现与数据就绪两个时间点」「预加载两种策略的对照」这几件带条件的、可核对的事。
图片查看器是我以为「装个组件就完了」的一块,结果因为要同时支持五种手势,它成了整个客户端里逻辑最绕的一个地方。四个问题全都是我自己在真机上反复操作时撞出来的。
一是放大之后想拖动图片,结果切到了下一张。我的实现是「单指横向移动就切换图片」。但图片放大之后,用户横向拖动的意图是「看图片的右半边」,而不是「换一张」——我把两种完全不同的意图用同一个手势判定了。
二是想下拉关闭,结果把图片拖歪了。我同时监听了「纵向移动关闭」和「拖动图片」。两个逻辑都被触发,图片一边跟手往下走、一边还在自己位移——动作看起来是坏的。
三是双击放大经常失效,被识别成两次单击(于是关闭了查看器)。我的单击处理是「点一下就关闭」。用户双击时,第一次点击立刻触发了关闭,第二次点击落在了已经关掉的界面上——双击放大这个功能实际上永远不会生效。
四是拖动过程中误触发长按保存。我的长按判定只看「按住超过多久」。用户按住图片开始拖动,拖了一会儿之后长按计时也到了——保存弹窗突然冒出来打断了拖动。
所以四个改动:用一个手势状态机统一裁决而不是各手势独立监听、放大状态下拖动优先给图片、拖到边界才交出滑动权、单击与双击用短延迟裁决、长按在移动超过阈值后取消。
这个模块最想说的一句话是:手势冲突的根因不是某个手势写错了,而是「同一个物理动作可以对应多种意图」,而我让多个独立的监听各自去猜。各自都猜得「有道理」,合起来就是互相打架。解法只能是把裁决权收到一处——由一个状态机根据触点数量、当前缩放状态、移动方向和距离,决定这一次动作归谁,而不是让五个监听各自认领。
用一个手势状态机统一裁决,而不是各手势独立监听。独立监听的写法最自然(每个手势一段代码、互不影响)。但它的前提假设是「这些手势不会同时被触发」——而这个假设在触摸交互里根本不成立:同一个物理动作可以对应多种意图,各个监听都觉得「这个动作是我的」。代价是所有手势的逻辑集中在一处,读起来更复杂。判据是「这些行为之间有没有互斥关系」:有互斥关系的行为不能各自独立判定,必须有一个地方做仲裁。这一条和我在请求层「令牌读写收敛到唯一入口」的道理是相通的——需要全局一致的决策,就不能分散。
放大状态下「拖到边界才交出滑动权」,而不是用一个显式的开关区分两种模式。显式开关(比如加一个「切换/拖动」的模式按钮)实现最简单、也不会误判。但它要求用户去学习和切换模式,而这是主流图片查看器都不需要的。「拖到边界才交出」的好处是它符合物理直觉:图片已经拖到头了、你还在往同一个方向推,那自然是想去下一张。代价是边界判定要准确(包括缩放后图片实际尺寸的计算)。判据是「能不能让用户不学习就用对」:能,那就值得多花实现成本。
单击加延迟裁决,接受「关闭有轻微延迟」这个代价。不加延迟的话单击响应最快。但它让双击放大永远不生效——因为第一次点击已经把查看器关了。而这个延迟很短,用户基本感觉不到。判据是「哪种损失更大」:一个功能完全不可用 vs 另一个功能慢几十毫秒,显然是前者更严重。这类「为了让 B 成立而牺牲 A 一点点」的取舍,关键是量清 A 被牺牲了多少——如果延迟长到用户能感觉出来,我的结论会反过来。
长按用「移动超阈值即取消」而不是「移动超阈值后仍允许长按」。后者更宽容(手抖也能触发长按)。但它会在拖动过程中突然弹出保存弹窗、打断拖动——而拖动是一个持续的动作,被打断的感受很糟。取消的代价是手抖的用户可能触发不了长按(他需要按稳一点)。判据是「误触发的代价」和「触发不了的代价」哪个大:误触发打断了正在进行的动作,代价更大。
踩过的坑一:放大之后想拖动图片,结果切到了下一张。我的判定是「单指横向移动就切换」。教训是:判定一个手势的意图,不能只看动作本身,还要看当前的状态——同样的横向移动,在未放大和已放大两种状态下,用户的意图完全不同。我一开始的实现完全没有把「当前缩放状态」纳入判定条件。
踩过的坑二:双击放大永远不生效。我查了很久为什么双击没反应,后来才发现问题不在双击的判定上,而在于第一次点击已经把查看器关了——第二次点击落在了一个已经不存在的界面上。教训是:当 B 功能「完全不生效」时,先看看有没有 A 功能提前把它的前提破坏了——我一直在检查双击的代码,而问题在单击那边。
踩过的坑三:斜着拖动时画面乱跳。因为横向和纵向的判定都在持续生效、交替触发。教训是:方向类手势必须在开始时锁定主方向、之后不再改变——持续判定方向会让斜向移动变成两种行为的高频切换。
没做的部分:没做双指旋转。它需要在缩放的基础上再叠加一个旋转量,而旋转与缩放的手势特征高度重合(都是双指),误判会很多;而且旋转之后「边界在哪」的计算会复杂很多(影响拖到边界才切换那条规则)。取舍依据是「这个手势的使用频率很低,而它会让已经调好的缩放与边界判定重新变得不可靠」——收益小、风险大,所以不做。
这个模块的验证以断言型用例为主,而且必须在真机上实际操作——不适合报性能数字。我的做法是把每一种冲突场景写成一条可复述的操作步骤,逐项回归。报法是「N 种手势冲突场景逐项验证通过」并列出清单——列清单本身就说明我把组合想全了。
放大后拖动不误切:放大图片、向右拖动,断言图片自身移动、没有切换到下一张;继续拖到右边界后仍向右移动,断言此时才切换。
双指不误触发切换:用双指做靠拢与张开,断言只发生缩放、不触发切换与关闭(这一条对应「两指一律进缩放且不参与其他判定」)。
斜向拖动不乱跳:斜着 45 度拖动,断言只发生一种行为(按首次超过阈值时的主方向),不出现切换与关闭交替触发。
双击放大生效:双击图片,断言放大且查看器没有被关闭——这条是踩坑的直接回归,改造前它是必然失败的。
单击关闭仍然可用:单击一次,断言在短延迟后关闭;并报出这个延迟的具体值,说明它在用户可感知阈值以下。
长按不误触发:按住图片并拖动超过阈值,断言保存弹窗不出现;按住不动,断言保存弹窗正常出现。两条一起测才说明阈值取得合理。
缩放中心:在图片的某个角落做双指张开,断言放大的是该角落而不是图片中心。
状态重置:放大后切换到下一张,断言新图片是未放大状态;双指松开一根手指,断言不会立刻被当成拖动开始。
不要报什么:不要报「手势识别准确率」——没有客观的分母,而且这类问题是「有或没有」而不是「多少比例」。该报的是「N 种冲突场景的清单与逐项验证结果」「单击关闭的延迟具体值」「长按取消的移动阈值」以及「测试是在哪些真机上做的」——手势相关的结论必须说明机型,因为不同设备的触摸采样和系统手势会有差异。
没有匹配的内容,换个关键词试试。
项目拆解 · 图文内容社区客户端(个人项目 · 无实习档)· 共 6 个模块 · 数字均为示例,需替换成自己项目的真实数据