“进入地图页时画面闪一下”——这类问题最容易被归到渲染优化或动画参数上。我第一次看到日志时也这么想,但把时间线摊开之后,问题其实出在调度时机:一次延迟重绘恰好落进了入场动画的窗口,而它来自一个不具备 UI 线程亲和性的定时器线程。

这次排查的完整链路值得记录,因为它同时涉及生命周期、线程模型和一个需要反汇编才能确认的 SDK 行为。

先描述清楚现象

  • 半屏地图在最小缩放级别、拖动到数据边缘后退出,再次进入时画面闪一下;
  • 全屏模式回归时也有同样的观感;
  • 稳定复现,不是偶发;
  • 不影响功能,但在嵌入式设备上非常显眼。

“稳定复现”是好事,意味着日志里一定能找到对应的时间点。

第一步:把日志和动画窗口对齐

页面入场时会启动一个 600ms 的转场动画,动画开始前页面还在屏幕外,所以此时做的任何渲染都不可见。

代码里有一个延迟重绘调度,常量写的是 260ms。看起来远早于 600ms 动画结束,应该安全。但日志显示的实际触发点是:

进入OnResume延迟回调执行回调内更新视图
第 1 次21:38:54.031+419ms+490ms
第 2 次21:39:01.187+575ms+658ms

第一次落在动画的 82% 处,第二次已经到动画尾部。原因很简单:260ms 不是从 OnResume 开始计时,而是从同步恢复渲染完成之后才开始计时。常量写的是延时,实际落点要按日志算。

这是本次排查最关键的转折点。在此之前,我一直在查渲染内容;对齐时间线之后,问题变成了“为什么这个时间点会有一次重绘”。

第二步:先证伪便宜的假设

排查时我刻意先排除成本低的可能,避免一上来就改绘制代码:

假设一:地图加载或主题动画导致。
日志显示地图实例是复用的,第二次进入走了“已初始化,跳过重复加载”;主题解析里那条 duration 越界的日志属于容器 enter 动画,和地图页的 X 转场无关。排除。

假设二:重复调用渲染接口本身就是闪的原因。
代码里当时有两处渲染调用被临时注释掉了。为了验证,我反汇编了 SDK 的渲染入口,看到两个判断:

  • 渲染前先比较当前姿态(中心点、缩放、角度、视口)与上次渲染后缓存的姿态,相等时再遍历图层是否需要重绘,都不需要就直接返回;
  • 设置视口的接口在宽高未变化时直接返回,不置脏;
  • 恢复渲染只清除暂停标志,不强制置脏。

结论是:更新视图内部已经渲染并更新了缓存姿态,之后那次重复渲染通常是空操作,不是闪的直接来源。两处注释随后被恢复。

假设三:构建链接报错和地图改动有关。
报错是 TLS 相关符号找不到,根因是第三方库端口文件里的条件编译宏在头文件包含之前判断,导致整个文件被裁空;触发条件是补丁脚本重复执行时非幂等。与渲染无关。

三个假设都被排除后,剩下的就只有“调度时机 + 线程”这一条。

第三步:确认根因

延迟回调在独立线程里执行,它做的事情远不止渲染:

  1. 重新绑定地图与视图;
  2. 恢复渲染;
  3. 更新地图尺寸;
  4. 更新视口并渲染;
  5. 再次渲染;
  6. 触发 UI 失效重绘;
  7. 重启预热渲染定时器。

而 UICanvasMapView::BeginDraw() 在真正开始绘制前,会先把整块地图缓冲清成白色。UI 动画帧如果恰好读到这块正在被清空或只画了一半的缓冲,看到的就是“闪一下”。

更关键的是,首次进入时这次延迟重绘本来就是冗余的:创建分支已经完整走了一遍加载、绑定、恢复、设视口、渲染;恢复函数又同步做了一遍绑定、恢复、更新、渲染、失效和预热。第三次再通过定时器线程重做一次,除了增加并发风险,没有带来任何新内容。

而渲染管理器本身也注明了“单线程渲染,视图数组无锁保护”。一个 worker 线程在这一刻修改视图尺寸和位置,本身就违反了这条约束。

所以根因可以写成一句话:首次进入时,一个由定时器线程驱动的冗余整屏刷新,落进了入场动画窗口,与 UI 帧并发读写同一块绘图缓冲。

放大条件也解释得通:最小缩放加拖动到数据边缘时,视口内的边界瓦片和空白填充更多,整屏重绘更慢,清屏窗口更容易跨过一帧。

第四步:修复方式不是加延时

最直接的想法是把 260ms 调大。但这是错的:

  • 延时是从同步恢复之后才开始算的,恢复耗时本身会波动,固定值永远只是“这次没撞上”;
  • 真正的问题是这条路径在首次进入时不应该存在,而不是它来得太早。

最终修复只做了一件事:把延迟重绘从 OnResume 里删掉,只保留在 OnStart 的“页面已存在、从后台恢复”分支里。

 void MapPage::OnResume()
 {
     ...
     RestoreMapViewAfterResume();
-    ScheduleDeferredResumeRender();
 }

保留路径的逻辑是:

if (isInit_) {
    RestoreMapViewAfterResume();
    ScheduleDeferredResumeRender();   // 仅页面已存在、从后台返回时调度
    return;
}

这个改动只有两个文件、+6 -2,但它体现了一个判断:延迟重绘不是“进场后补一刀”,而是“已存在的页面恢复后补一刀”。首次进入由生命周期前半段负责,从后台恢复才交给延迟机制。同一套恢复逻辑,在不同生命周期阶段的时序要求并不一样。

验证要覆盖两条路径

修复后我重点验证了:

  • 连续多次进入退出,半屏和全屏都不再闪,日志里首次进入不再出现延迟回调;
  • 后台切回、从子页面返回时,OnStart 仍会走“已启动”分支并执行延迟重绘,地图能正常刷新;
  • 完整构建通过,镜像正常生成。

第二条容易被忽略。如果只验证“不再闪”,很可能把恢复路径一起改坏,而那个问题要等用户切后台才会暴露。

这次排查沉淀下来的经验

  1. 固定延时不是安全延时。任何“等一会儿再刷新”的代码,都要按日志算实际落点,而不是相信常量。
  2. worker 线程不要碰 UI 树和共享绘图缓冲。即使渲染接口本身是懒渲染,视图尺寸和位置的修改也可能造成撕裂。
  3. 生命周期要分治。首次进入和从后台恢复是两条路径,不能用同一套调度覆盖。
  4. SDK 行为要验证再下结论。重复渲染是否为空操作,是靠反汇编确认的,不是靠猜。
  5. 先证伪便宜的假设。主题动画、重复渲染、构建报错,这三个假设的排除成本都远低于改渲染代码。

总结

这次闪屏不是地图数据问题,也不是动画参数问题,而是一次冗余的、跨线程的整屏刷新落进了动画窗口。

排查路径可以概括为:先对齐时间线,再排除低成本的假设,然后用证据确认 SDK 的真实行为,最后在生命周期层面做分治,而不是在延时数字上做微调。

对嵌入式 UI 来说,“什么时候刷新”和“在哪个线程刷新”,往往比“刷新得多快”更重要。

Logo

社区规范:仅讨论OpenHarmony相关问题。

更多推荐