ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

鸿蒙 Flutter 应用如何用 rbush 空间索引解决百万点位性能瓶颈

鸿蒙 Flutter 应用如何用 rbush 空间索引解决百万点位性能瓶颈 如果你最近正在鸿蒙设备上用 Flutter 做地图、LBS 或者游戏类的应用大概率会遇到一个非常实际的问题点位一多界面就开始卡。尤其是那种要同时展示几千上万个动态点位的场景拖动地图像在翻幻灯片FPS 掉到个位数是常事。我之前做的一个原型就是栽在这里后来排查发现性能瓶颈根本不在绘制而在于我每帧都把全量点位遍历一遍做视口筛选。这个问题的最佳解法不是优化循环而是引入空间索引把点位组织成树状结构用查询代替遍历。我在 JavaScript 生态里找到了一个非常顺手的库——rbush它基于 R-Tree 算法专做 2D 空间索引。这篇文章记录的就是我把它完整适配进鸿蒙系统、并在 Flutter 侧跑通大规模点位碰撞检测的全过程包括算法原理、源码改造、ArkTS 封装、性能实测和一堆踩坑记录适合正在做鸿蒙化 Flutter 应用的开发者参考。1. 项目整体设计为什么偏偏选中 rbush 来搭空间索引1.1 R-Tree 到底解决了什么问题先聊一个听起来基础、但特别容易被忽视的问题空间查询的复杂度。假设你现在有 100 万个点位每帧屏幕变化后要知道哪些点落在当前可视区域内最直觉的做法是 for 循环逐个判断这一步就是 100 万次比较。就算每次比较只花 0.1 微秒一帧下来也要 100 毫秒这个开销直接让 UI 卡死。更不用提如果在碰撞检测场景里要做两两判断100 万个点两两比较就是万亿级别那是完全不可能实时计算的。R-Tree 解决的就是这个“空间范围查询”的效率问题。它不是把每个点当成独立元素线性排列而是把空间坐标抽象成一个个包围盒bounding box底层的点会按照位置就近聚在一起每个聚簇有一个最小的外包矩形。把这些聚簇的矩形放到上一层继续聚合逐层往上最终形成一棵高度平衡的树。查询的时候只需要从根节点往下深度遍历判断查询矩形与节点矩形是否相交不相交的整个子树直接跳过。这就像你在图书馆找书先看哪个区域再锁定哪个书架最后精确定位到某一层而不是从第一本书开始逐本翻。R-Tree 的查询复杂度在理想情况下接近 O(log n)即使在最坏情况下也比全量遍历好得多。尤其当点的分布不均匀、存在大量聚类时R-Tree 对空白区域的裁剪效果非常明显。这也是它成为工业级空间索引首选的原因之一。rbush 就是这个算法在 JavaScript 世界里最具代表性的实现代码极简、无依赖、API 克制所以我一看到它就想把它带到鸿蒙生态里来。1.2 空间索引方案对比为什么不是四叉树或网格索引在确定用 R-Tree 之前我也认真比较过四叉树和网格索引这两种方案。四叉树的思路是把空间递归分成四个象限动态插入时根据点落在哪个象限决定递归路径。它写起来很直观很多游戏引擎首屏碰撞用的都是四叉树但四叉树有两个问题一是树的深度受最大递归层数限制点分布极不均匀时可能出现某个叶子节点里挤了几万个点退化非常严重二是需要频繁处理“树不平衡”的情况动态删除节点后需要做很多重建工作。网格索引Grid Index更简单就是把空间划分成固定大小的格子每个点挂到所在格子里。查询一个矩形时只需要遍历与矩形相交的格子即可。听起来很完美但格子大小是很难调的参数格子太小内存占用爆炸格子太大一次查询仍然要扫很多点。它适合数据分布相对均匀的离散事件模拟遇到点位密集或稀疏差距大的真实地图数据就露馅了。R-Tree 的优势在于它是动态平衡的树结构不依赖人为设置网格密度也能适应不均匀分布。rbush 在学术版 R-Tree 的基础上还引入了类似 R* Tree 的插入策略会在节点溢出时触发节点分裂和重新插入尽可能减少节点矩形之间的重叠。这意味着查询时被裁剪的无效区域会更多命中路径更短。对比下来R-Tree 在“边际收益”和“落地成本”之间是最平衡的。1.3 rbush 的三个硬优点零依赖、纯数据、API 克制选型最后落在 rbush 上其实是看中了它三个非常适合鸿蒙化改造的特质。第一是零依赖rbush 的核心源码几乎不依赖任何第三方运行时库npm 包里不需要安装一大串 node_modules这意味着移植到鸿蒙时不用处理复杂的依赖链直接把核心文件搬过去改类型就能跑。第二是纯数据它不操作 DOM不依赖 window 或 document 这些浏览环境 API纯粹做数组运算和递归操作这满足了鸿蒙侧运行环境的基本要求。第三是 API 极其克制核心方法就是 load、insert、remove、search、collides满打满算五个每一个都对应一个非常明确的空间操作语义。这对做跨端封装特别友好因为给 Flutter 暴露 MethodChannel 接口时不需要设计复杂的调用协议直接一对一映射即可。rbush 还支持批量插入的 bulkLoad 模式它能在一次插入中先把所有点排好序再按层构建树效率远高于逐条 insert。这个特性对鸿蒙化场景来说是决定性的因为 Flutter 和原生侧通信一次的成本不低如果能一次性把上万条数据通过扁平数组传过去让 rbush 在原生侧做批处理收益远高于几百次小调用的叠加。2. 鸿蒙化适配的路径拆解让 JS 库在 OpenHarmony 中真正跑起来2.1 先弄明白 Flutter 与鸿蒙原生之间的能力边界在动工之前我花了两天时间梳理 Flutter 和鸿蒙原生之间的调用链路。Flutter 应用跑在 DartVM 上Dart 侧代码通过 MethodChannel 和原生平台通信。鸿蒙这一侧的原生能力由 UIAbility 承载用的开发语言是 ArkTS整个应用运行在方舟运行时里。这就带来一个现实约束rbush 本身是 JavaScript/TypeScript 风格代码如果直接塞到 Flutter 侧Dart 无法直接加载 JS 模块但放到 ArkTS 侧它能有相对统一的类 TypeScript 类型系统改造起来最顺。所以我的技术决策非常明确把 rbush 移植成一个可在 ArkTS 工程中直接 import 的类库或者更准确地说将它改写成符合 ArkTS 约束的纯 TypeScript 模块运行在鸿蒙原生这一侧然后通过 MethodChannel 给 Flutter 提供一个异步的、可批量执行的空间索引服务。这里的核心考量是“让计算发生在离数据最近的地方”。地图点位数据首先到达的是原生侧还是 Flutter 侧其实取决于你的架构但空间索引本身是纯计算逻辑不需要访问 UI 组件或系统服务。把它放到鸿蒙侧独立模块里一方面避免了 Flutter Channel 高频调用导致的消息排队开销另一方面也方便日后直接用 TaskPool 或 Worker 把建树和查询任务放到后台线程去跑彻底不阻塞 UI。2.2 三条改造路线横向对比源码移植、ohpm 桥接、原生重写在开始写代码之前我也评估过另外两条路线这里放一张当时做的对比表你可以直观看到我为什么最后选了源码移植。改造路线改造成本运行时效率可控性推荐度直接源码移植为 ArkTS/ETS 模块中需要清理类型和模块格式高方舟运行时直接执行高可自由优化强烈推荐通过 ohpm 发布/引入桥接包低但包生态还不成熟中依赖包内部实现低遇到问题要改三方包源码看情况用 C 按 R-Tree 算法重写高工作量大、测试成本高极高可结合 Native 层计算最高但工期不可控不推荐短期做ohpm 是鸿蒙生态的包管理工具现在已经有不少基础库能直接通过它引入。但 rbush 这类偏专业的算法库在鸿蒙侧的适配包还很稀缺即便有我也担心维护状态不明。而 C 重写虽然性能潜力最高但工期太长而且我其实不需要更高性能——rbush 的算法复杂度已经足够瓶颈根本不在它内部而在于数据在两端之间怎么传。做工程要选低成本高杠杆的路径所以最终就是源码移植。补充一个信息如果你在鸿蒙工程里尝试直接 import npm 格式的 rbush大概率会遇到模块加载失败。鸿蒙 ArkTS 环境对模块导入有着严格的路径、后缀和格式限制npm 包里的 CJS 产物在方舟运行时上并不能直接兼容。所以源码移植不是可选项而是几乎唯一的可控方案。2.3 rbush 源码里需要处理的关键“坑点”我在改造 rbush 源码的时候遇到了几个值得拿出来讲的坑点提前知道能省半天时间。第一个坑是模块格式。rbush 的 npm 包默认提供了 UMD 和 ESM 两种产物但它的代码内部如果还有少量 CommonJS 风格导出在鸿蒙侧就无法直接 import。解决方案很直接找一份源码级的 TypeScript 版本或者把它的 ESM 文件手动规整成纯 TS 模块。第二个坑是类型推断。rbush 内部为了追求极致的性能节点里的坐标数据是用并行数组存储的很多地方会用动态属性访问来维护节点结构。ArkTS 对类型的约束比普通 JS/TS 严格尤其是对 any 类型和动态索引的限制所以需要在节点对象的数据结构上做显式类型声明字段类型全部标清楚。第三个坑是 TypedArray 的兼容性。rbush 运行时会把坐标数组转成 Float32Array 或 Float64Array大多数鸿蒙设备上这是没问题的但要避免使用那些依赖 node 环境的 Buffer API。整体来说这些坑都是适配阶段的“体力活儿”但不解决就会反复在运行时崩。一个值得强调的细节是保存输入坐标的顺序。rbush 对每一个空间对象的描述是一个元组[x0, y0, x1, y1]它并不直接保存点坐标而是把每个点位都当成一个矩形包围盒。如果你传入的是一个点正确的姿势是把 x0 和 x1 都设置成同一个 x 值y0 和 y1 也都设置成同一个 y 值。很多人在鸿蒙化调试时发现 search 返回空数组就是因为把四个坐标的含义记反了或者把点坐标直接当成经纬度传进去没有做过边界归一化。2.4 ArkTS 层 API 设计给 Flutter 留一套“小而美”的接口在封装原生侧服务时我坚持一个原则接口越小越好语义越明确越好。最终我给 Flutter 侧暴露了五个 MethodChannel 方法分别是initIndex、loadPoints、insertPoint、searchRect、collides。其中loadPoints接收一个扁平化的 double 数组每四个一组代表一个矩形或点位searchRect接收查询矩形的四个边界值返回所有命中对象的索引数组这样 Flutter 侧可以通过索引回到自己的数据源里取完整对象避免在 Channel 之间传递大对象。collides则是专门的碰撞检测入口判断给定矩形范围内是否存在任何空间对象适合做鼠标拾取、游戏碰撞预判这类只需要 bool 结果的场景。这种设计的优点是数据在 Channel 中传输的时候尽量避免复杂的 Map 或 List 嵌套结构统一用扁平数组作为传输协议。鸿蒙侧解析一个扁平数组比解析一串 JSON 字符串快得多也减少了 GC 压力。Flutter 侧在 Dart 里拿到扁平结果后再用一个非常薄的封装类把索引还原成结构化的点对象。通信协议越简单排查问题的成本就越低。3. 实操记录从零到一完成 rbush 鸿蒙化并集成进 Flutter3.1 获取源码并裁剪成 ArkTS 可接受的模块第一步是拿到源码。我直接从 GitHub 上拉取 rbush 的仓库然后找到它的核心源码文件。如果你在自己的项目里操作可以执行git clone https://github.com/mourner/rbush.git cd rbush npm install拉下来之后不要急着复制整个项目只需要把src目录下与 RBush 算法相关的核心代码复制到鸿蒙工程的entry/src/main/ets/rbush目录下。目标工程的目录结构大致如下entry/src/main/ets/ ├── rbush/ │ ├── RBushCore.ets │ ├── SpatialIndex.ets ├── service/ │ └── SpatialIndexService.ets └── entryability/ └── EntryAbility.ets在改造成 .ets 文件之前先删掉 npm 安装生成的所有依赖文件只保留 rbush 的核心逻辑代码。核心逻辑其实主要集中在 RBush 类里包括节点分裂、选择子树、批量加载和范围查询。由于它本身没有外部依赖直接 Copy 进去后做类型标注和语法兼容就行。能走到这一步你就已经绕开了最让人头疼的依赖链问题。3.2 用 ArkTS 重写一个线程安全、可批量操作的空间索引封装为了让 rbush 在 ArkTS 环境下更“听话”我没有直接暴露原始对象而是额外包了一层SpatialIndex类。这个类负责将外面传入的扁平数组转换成 rbush 内部的空间条目同时对外提供更友好的批处理入口。核心代码思路大致如下class SpatialIndex { private tree: RBushArraynumber; constructor(maxEntries: number 9) { this.tree new RBushArraynumber(maxEntries); } loadFromFlatArray(data: Arraynumber): void { const items: ArrayArraynumber []; for (let i 0; i data.length; i 4) { items.push([data[i], data[i 1], data[i 2], data[i 3]]); } this.tree.load(items); } searchRect(x0: number, y0: number, x1: number, y1: number): Arraynumber { const result this.tree.search([x0, y0, x1, y1]); return result.map(item item[0]); } collides(x0: number, y0: number, x1: number, y1: number): boolean { return this.tree.collides([x0, y0, x1, y1]); } clear(): void { this.tree.clear(); } }这里有两个细节需要解释。第一个是maxEntries参数也就是每个节点允许的最大条目数默认是 9。这个值在大多数场景下是最优的如果点位特别密集或者矩形特别大可以尝试调整到 16但对普通 2D 场景我不建议随便乱动。第二个是search返回结果直接 map 出每个条目的第一个字段这个字段是我在批量加载时塞进去的对象索引可以用来关联 Flutter 侧的数据源。用扁平数组传输索引比传整个坐标对象要轻得多。3.3 在 EntryAbility 和 TaskPool 之间建立稳定的计算通道算法封装好之后下一步就是让它能被 Flutter 侧调用。我在 UIAbility 里注册了一个SpatialIndexService核心作用是接收 MethodChannel 发来的消息然后分发给底层的SpatialIndex实例。这里有一个重要的架构决策建树和查询计算最好放到 TaskPool 后台线程里执行不要直接在 UI 线程上跑。因为百万点位的批量建树虽然只有几百毫秒但放在 UI 线程上依然会造成可感知的卡顿。鸿蒙的 TaskPool API 可以通过Concurrent装饰器把函数放到后台线程执行关键代码类似import { taskpool } from kit.ArkTS; Concurrent function doBuildIndex(data: ArrayBuffer): void { const index buildIndexFromBuffer(data); taskpool.Task.sendData(index); }不过要注意TaskPool 里的函数无法直接持有SpatialIndex实例的引用它需要一种“序列化输入、序列化输出”的方式。所以我在主线程侧维护了一个SpatialIndex长驻实例TaskPool 只处理纯计算型的任务比如批量加载前的排序和格式化加载完成后再把结果数组一次性传回主线程。这样可以避免频繁创建树对象带来的性能损耗也避免因为并发写同一个索引实例导致数据错乱。3.4 Flutter 侧 Dart Channel 封装与碰撞检测调用原生侧的服务注册好之后Flutter 侧就可以通过MethodChannel与之通信了。我的 Dart 侧封装非常简单只保留了一个RbushBridge类负责初始化 MethodChannel 和为上层提供异步方法class RbushBridge { static const MethodChannel _channel MethodChannel(spatial_index); Futurevoid loadPoints(Listdouble flatPoints) async { await _channel.invokeMethod(loadPoints, flatPoints); } FutureListint searchRect({ required double x0, required double y0, required double x1, required double y1, }) async { final result await _channel.invokeMethod(searchRect, [x0, y0, x1, y1]); return (result as Listdynamic).castint(); } Futurebool collidesRect({ required double x0, required double y0, required double x1, required double y1, }) async { final result await _channel.invokeMethod(collides, [x0, y0, x1, y1]); return result as bool; } }封装完成后碰撞检测的实际调用逻辑就非常轻量了。比如在游戏场景里判断子弹是否碰到障碍物不再需要遍历所有障碍物只需要先把所有障碍物的包围盒批量 load 进树每一帧用子弹的包围盒调用collidesRect原生侧立刻返回是否有碰撞整个过程就是一次 MethodChannel 调用加一棵树的快速剪枝耗时可以忽略不计。很多 Flutter 开发者容易犯的错是在 Dart 侧每次都把点位转成 JSON 字符串再传过去然后在原生侧解析 JSON。实测下来这会在百万点位场景产生严重的 CPU 和内存开销。我的建议是直接在 Dart 侧用 Float64List 转Listdynamic或者干脆传字节缓冲保证两端之间传的是最简单的数字序列。在 MethodChannel 里传大量数字数组要比传字符串高效得多。4. 性能实测百万点位下的空间索引与碰撞检测表现4.1 实测方法论与结果对照移植完成后我在一台普通的测试设备上做了几组对照实验分别测试暴力遍历、rbush 批量建树、矩形查询和碰撞检测这四种场景。点位规模从 1 万、10 万到 100 万逐步递增数据分布模拟了地图上常见的“局部密集、全局稀疏”的状态。测试方法是在同一个设备上先跑一次全量遍历再跑一次 rbush 版本记录下来的是单次操作的平均耗时每一组都重复 100 次取中位数。数据规模全量遍历窗口查询rbush 批量建树rbush 矩形查询单点碰撞检测1 万点约 1.5ms约 1ms约 0.02ms约 0.01ms10 万点约 16ms约 35ms约 0.1ms约 0.03ms100 万点约 150ms 以上约 400ms约 0.8ms约 0.1ms需要强调这些数字只代表我手头测试设备上的相对表现不同机型差异会很大但趋势是一致的建树的成本是一次性的而查询和碰撞检测的收益是持续性的。换句话说只要点位不是你每帧动态全量更新花几百毫秒建一次树完全值得。4.2 碰撞检测场景下的调优实践碰撞检测是这次适配的核心应用场景之一也最容易踩到性能坑。最朴素的做法是遍历所有物体然后每次判断矩形相交。这种方式在游戏场景里会写成双重循环for (var a in objects) { for (var b in objects) { if (a ! b a.rect.overlaps(b.rect)) { ... } } }这种写法的可怕之处在于物体有 1000 个两两组合就有 50 万次判断物体有 1 万个就是 5000 万次判断FPS 必然崩掉。正确的做法是先给所有物体的包围盒建好空间索引然后在需要判断某个物体的碰撞范围时只查询与它发生重叠的候选对象再做真正的精细碰撞判断。比如在碰撞检测函数里先拿到子弹包围盒调用collidesRect快速判断这一小片区域是否存在任何障碍物包围盒如果有再用分离轴定理做多边形精判。这样就把 O(n²) 的运算量压缩到了 O(候选对象数量) 量级而且绝大多数候选对象在空间上根本不相邻粗筛阶段就会被砍掉。关于碰撞检测函数还有一个很实用的技巧不要一开始就用精确的多边形相交算法。多边形的分离轴定理计算量比矩形相交大得多如果先用矩形包围盒做粗筛再用精判整体耗时能再降一个量级。我实际测试下来1000 个复杂多边形之间的碰撞检测用暴力双重循环要十几毫秒但先通过 R-Tree 粗筛之后只需要对几十个候选对象做精判总耗时不超过 1 毫秒这个差距在实时交互场景里是决定性的。4.3 内存与 GC 优化让百万点位不被 GC 拖垮不得不承认第一次完整跑通百万点位的时候App 并没有立刻崩溃但操作一段时间后开始频繁掉帧后来看日志才发现是 GC 在捣鬼。原因是我为了省事在 Dart 侧把百万点位的包围盒全部转成了ListMapString, double然后在 Channel 传参时又经历了一次对象拷贝每次 GC 都要扫描这一大堆临时对象性能自然崩了。优化方案很直接所有点位用Float64List保存和传输原生侧拿到后直接用扁平数组构建索引不再构造中间层 Map 对象。另外在鸿蒙侧也尽量避免把查询结果包装成自定义对象返回给 Dart直接返回Int32List让 Dart 侧用一个高性价比的映射函数去还原数据。还有一个容易被忽视的细节R-Tree 的叶子节点保存的是包围盒数组如果把每个矩形都封装成一个独立对象百万个矩形就意味着百万个对象内存占用很夸张。rbush 已经做了数组扁平化存储的优化但在鸿蒙侧做封装时要注意别把这份优化抵消掉。我的经验是不要在 ArkTS 侧把每条索引结果转成Map直接返回原始索引值。可以把这理解为“索引只负责告诉你位置不负责搬运数据本身”。5. 常见问题与排查技巧实录5.1 问题速查表适配和集成过程中我确实踩了不少坑这里整理成一张速查表方便你有问题直接定位。问题现象可能原因排查方法解决方案编译时报Cannot find module rbush工程内没有把 rbush 源码导入或路径缺少扩展名检查 import 路径是否完整ArkTS 要求带.ets后缀使用相对路径导入核心源码文件并在 build-profile 中配置正确资源路径批量加载后search总是返回空数组传入的四个坐标顺序写错或没有把点转成零宽矩形打印前几个条目检查[x0, y0, x1, y1]的语义点坐标的 x0 与 x1 取相同的 xy0 与 y1 取相同的 y数据量上万之后加载特别慢没有走load批量加载而是逐条调用insert看堆栈里是否频繁调用 insert一次性把数据整理成数组调用tree.load(items)内存占用翻倍Channel 里传输了 JSON 字符串或复杂对象用 DevTool 查看内存快照改用Float64List或ArrayBuffer传递扁平数组碰撞检测偶尔漏判浮点数精度导致包围盒边缘恰好不相交增加容差值打印调试在比较边界时增加一个EPSILON微调量在 TaskPool 中访问SpatialIndex报错并发函数无法直接捕获外部实例引用检查并发函数的传参是否支持传对象把索引实例保持在主线程并发任务只负责数据预处理5.2 独家心得鸿蒙环境下“快”的三层含义这次适配给我最大的启发是在鸿蒙环境下追求“快”并不是单纯指算法复杂度更低。第一层快是数据结构带来的查找快。把全量遍历变成树形搜索查询效率的提升是数量级的这是 rbush 本身的价值。第二层快是通信协议设计带来的快。鸿蒙和 Flutter 之间传递数据如果设计不好什么算法都会被 IO 和拷贝拖死。扁平数组、批量操作、原生侧直接计算这三条组合拳让整个响应链路保持在低延迟。第三层快是线程调度带来的快。利用 TaskPool 把重计算搬到后台UI 线程永远不阻塞这才是“极致极速”的真正含义——不是某一处代码跑得快而是整个工程架构让用户感知不到延迟。这三层缺一不可。如果你只引入了 rbush 算法但每次查询都要在 Flutter 和原生之间来回传 JSON性能依然会很差如果你只优化了通信但在主线程里跑百万点建树界面照样会掉帧。5.3 最后再说一个调试技巧如果你在鸿蒙侧调试时发现 R-Tree 的查询结果和你的预期对不上强烈建议先用“极小数据量”跑通验收逻辑比如只插入 5 个点查询一个确定包含其中 2 个点的矩形打印返回结果。再把数据量逐步放大。这个习惯能帮你把“算法问题”和“数据问题”快速区分开。我当时就是跳过了这一步直接用百万点调试结果出了 bug 后在数据洪流里翻来翻去找不到原因白白浪费了半天。另外你在做碰撞检测精确判断时不要忽略矩形包围盒与真实多边形之间的差异。R-Tree 只能帮你做粗筛给你一份“有可能碰撞”的候选列表真正决定最终结果的还是精细多边形相交算法。在一次地图应用的框选需求里我用 R-Tree 筛完候选后剩余的多边形数量往往从几千降到几十再用几何算法逐一精判整个流程的耗时完全可控。适配 rbush 到鸿蒙的这条路上最大的收获其实是让我重新理解了空间索引的价值。它不是一个可以简单“优化循环”的补丁而是从数据结构层面改变问题的复杂度。有了这个基础后续无论做大规模点位渲染、动态碰撞检测还是复杂空间分析都能站在一个很高的起点上。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进