
前三篇文章讲了 KuiklyUI 的分层结构、Core/Bridge/Render 各自的职责和它们为什么这样分工。这一篇换一个角度:从一个页面被宿主 App 打开开始,到用户在屏幕上看到第一帧,中间到底发生了什么。
先说一个会在后面反复出现的判断:KuiklyUI 的页面创建不是一次性渲染,而是分成了两个阶段。先在 Kotlin 侧构建一棵完整的内存 view 树,再在 createBody() 阶段把这棵树逐节点翻译成 Native 侧的原生 View 树。
在讲创建链路之前,需要先看清楚 Pager 在 KuiklyUI 里的角色。
宿主 App 打开一个跨端页面后,Core 侧需要有一个对象承接这个页面实例:它要保存页面 ID,管理生命周期,持有 view 树,也要在 Native 事件回传时负责路由。
在 KuiklyUI 里,这个对象叫 Pager。第一篇提过它是页面抽象、拥有完整生命周期,但那只是它身份的一面。
Pager 在 KuiklyUI 里有一个更加底层的角色:它不只是页面对象,同时是整棵 view 树的根节点。

你写的 View { }、Text { } 这些组件,最终都作为子节点挂在 Pager 下面。Pager 不是站在树外面管理这棵树,它就是树本身。这个身份决定了后续很多事情:生命周期回调挂在这里、ReactiveObserver 以它为隔离单元、viewMap 的注册以它为入口、Native 事件回传也通过它的 pagerId 路由到正确的实例。
第一节讲了 Pager 是页面抽象同时也是 view 树的根节点。Native 通过 Bridge 把创建信号发给 Kotlin 侧时,附带的是一个页面地址字符串。Kotlin 侧手里需要拿到一个具体的 Pager 子类实例——中间这段"字符串到实例"的翻译,KuiklyUI 分两步完成:编译期生成路由表,运行时按表创建。
第一步发生在编译期。KuiklyUI 用 KSP(Kotlin Symbol Processing,Kotlin 的编译期代码生成工具)扫描所有带 @Page 注解的类,为每个目标平台生成一份入口文件。可以把它简单理解成构建期生成路由表,只不过 KSP 运行在 Kotlin 编译器内部,可以直接访问类型信息。
KSP 从扫描到路由表就绪的完整流程:

关键点:到这一步为止,Kotlin 侧还没有任何 Pager 实例,只有一张路由表。KSP 的作用是把页面地址和 Pager 子类之间的映射关系,在编译期确定下来。页面路由到 Pager 子类这一步,运行时不再靠反射查找。
第二步是运行时。Native 侧发起 CREATE_INSTANCE 调用后,BridgeManager 根据 methodId 路由到 PagerManager.createPager(),后者从路由表找到对应的工厂函数、new 出 Pager 实例,然后进入 onCreatePager() 启动生命周期。具体每一步的触发时机和执行顺序,第三节的生命周期图会更直观地展开。
第二节末尾说到 Pager 实例创建后进入 onCreatePager(),启动生命周期。完整的时序见下图:

这张图里有一条明确的分界线:didInit() 的 body() 执行完之后,图上标了 ⚠ 到此都只是 Kotlin 内存操作,Bridge 指令还没发出。以此为界,前面的操作全部在内存中完成;后面的 createBody() 才开始向 Native 发指令。页面创建分成内存阶段和渲染阶段两段,这一节看前半段。
onCreatePager() 先跑三步准备:willInit() 留给子类覆盖,initModule() 注册内置 Module(惰性创建,只存工厂函数不实例化),didMoveToParentView() 把 Pager 自己注册进 view 索引。跑完这三步,Pager 环境就绪,但页面上还什么都没有。
接下来执行业务代码写的 DSL 块,在内存中把整棵 view 树搭起来。这个过程是深度优先的:每遇到一个 View {} 块,创建一个 view 实例,把 attr {} 里的属性存入内存,把 event {} 里的回调存入内存,如果是容器型 View 就继续递归处理子节点。attr {} 块里如果读了 observable 字段,依赖关系也在这一步建立(具体机制下一篇讲)。
所有这些操作只在内存里完成,不发任何 Bridge 指令。跑完之后,内存中有一棵完整的 view 树,每个节点的属性、事件、父子关系全部就绪。但 Native 侧完全不知道这棵树的存在。下一节 createBody() 才是把它翻译成渲染指令的起点。
第三节结尾说,body() 执行完后内存中有一棵完整的 view 树,Native 侧还不知道它的存在。createBody() 的任务就是把这棵树逐节点翻译成 Native 能执行的指令。它内部分四步:

第一步 createFlexNode():递归遍历 view 树,为每个节点创建一个 FlexNode(布局计算用的数据结构)。这一步还是纯计算,不发 Bridge 指令。
第二步 createRenderView():从这里开始发 Bridge 指令。递归遍历 view 树,每个节点依次做三件事:发 CREATE_RENDER_VIEW 让 Native 创建对应的原生 View,发 SET_VIEW_PROP 把之前积累的属性逐条下发,发 INSERT_SUB_RENDER_VIEW 挂到父节点下面。先设属性再挂节点的顺序是刻意的,如果反过来,子 View 挂上去时属性还不齐,Native 侧后续的布局和渲染时机更难控制。
第三步 insertToRootView():Pager 自身的 RenderView 用约定的 ROOT_VIEW_TAG = -1 挂到 Native 根容器上。其他所有 View 都通过 INSERT_SUB_RENDER_VIEW 挂在父 View 下面,只有 Pager 直接挂在容器顶层,这是它作为根节点的特殊待遇。
第四步 layoutIfNeed():FlexNode 跑布局算法,算出每个节点的坐标和尺寸,结果通过 SET_RENDER_VIEW_FRAME 下发给 Native。布局最多迭代 3 轮:第一轮基于约束算出初始 frame,但 Text 这类组件需要异步从 Native 拿真实测量尺寸(文字宽度受字体渲染影响),回填后相关节点重算;两轮通常稳定,第三轮留余量。超过 3 轮还没收敛,投递到下一帧异步重试。
layoutIfNeed() 完成后,Native 侧有了完整的 View 层级和布局坐标。接下来各平台走自己的绘制管线:Android 的 measure/layout/draw、iOS 的 UIKit 渲染、Web 的浏览器合成器。这部分 KuiklyUI 不介入。
当 Native 侧完成第一帧绘制,通过 Bridge 回报一个 UPDATE_INSTANCE 事件(eventName 为 "pageFirstFramePaint"),Core 侧收到后触发 onFirstFramePaint() 回调。
Core 侧 view 树创建完毕和用户真正看到画面之间有一段不可忽略的时间差。跨端框架需要由 Native 回报首帧状态,不能假定 Core 侧工作做完用户就已经看到了。
Pager 不只是页面对象,它同时也是整棵 view 树的根节点。业务代码的组件层层挂在它下面,生命周期、响应式上下文、事件回传都以它为中心组织。
页面创建分两个阶段。第一阶段在内存中构建完整的 view 树,不产生任何 Bridge 通信;第二阶段 createBody() 才把内存树逐节点翻译成 Native 渲染指令。这个分离让 Core 层可以先完整准备好一棵树,再批量推给 Render 层。代价是时序约束变多:attr 依赖在 body() 阶段建立、viewMap 注册紧跟 didMoveToParentView、RenderView 创建只能在 createBody() 里做,这些步骤不能随意调换顺序。
首帧的绘制和确认跨了两个阶段。Native 走自己的绘制管线,完成后通过 Bridge 回调通知 Core 侧。跨语言边界两侧的状态不是同步推进的。
下一篇进入响应式系统:既然 attr {} 在 body() 阶段建立了依赖关系,那么后续状态变化时,KuiklyUI 怎么精确地只重新执行相关 attr 块并下发 Bridge 指令。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。