Fleks 鸿蒙适配:从八个小球看实体和组件的变化

1. 场景从八个实体开始

先不急着做游戏。画布上放八个会移动的小球,再留几个按钮:新增、删除、切换速度、清空。这样一个很小的场景,已经足够把 ECS 的几种状态变化放到眼前:实体创建后进入查询集合,组件被移除后退出集合,最后整个场景被清理。

Fleks 是 Kotlin Multiplatform 的 ECS 库。这次按 CPF-KMP-CMP 的鸿蒙 Native 路线接入共享代码,让 Fleks 管理实体,让 ArkTS 显示画布。先把这条调用链做小,再逐步观察组件和系统之间的关系,比一开始塞进完整游戏更容易判断问题出在哪里。

每个小球携带 Position 和 Velocity。前者保存坐标,后者保存速度;实体本身提供身份,把这些组件联系起来。MotionSystem 使用 all(Position, Velocity) 定义 Family,只有同时具有两种组件的实体才参加位置更新。

更新公式很简单:坐标加上速度乘以 deltaTime。示例里的反弹也放在同一个系统里,超过边界后反转速度并限制坐标。这里的边界处理用于让小球留在画布内,碰撞和物理求解尚未实现。

初始化时,resetScene 先释放旧 World,再创建新 World 和八个实体。后续按钮也操作这份 World。下面保留完整 Kotlin 入口,创建、更新、组件切换和检查代码都能沿着函数名找到。

复制代码
package harmony.fleks

import com.github.quillraven.fleks.*
import com.github.quillraven.fleks.World.Companion.family
import kotlin.math.abs

private data class Position(var x: Float, var y: Float) : Component<Position> {
    override fun type() = Position
    companion object : ComponentType<Position>()
}
private data class Velocity(var x: Float, var y: Float) : Component<Velocity> {
    override fun type() = Velocity
    companion object : ComponentType<Velocity>()
}
private class MotionSystem : IteratingSystem(family { all(Position, Velocity) }) {
    override fun onTickEntity(entity: Entity) {
        val p = entity[Position]
        val v = entity[Velocity]
        p.x += v.x * deltaTime
        p.y += v.y * deltaTime
        if (p.x < 3f || p.x > 97f) { v.x = -v.x; p.x = p.x.coerceIn(3f, 97f) }
        if (p.y < 5f || p.y > 95f) { v.y = -v.y; p.y = p.y.coerceIn(5f, 95f) }
    }
}
private fun newWorld() = configureWorld { systems { add(MotionSystem()) } }
private var scene = newWorld()
private var frames = 0
private var serial = 0
private var note = "位置和速度由 Kotlin / Fleks 更新"

private fun addParticle() {
    val n = serial++
    scene.entity {
        it += Position(12f + (n * 13 % 72), 15f + (n * 19 % 65))
        it += Velocity(if (n % 2 == 0) 12f else -10f, if (n % 3 == 0) 8f else -6f)
    }
}
fun resetScene(): String {
    scene.dispose(); scene = newWorld(); frames = 0; serial = 0
    repeat(8) { addParticle() }
    note = "8 个实体,各自携带 Position 与 Velocity"
    return sceneSnapshot()
}
fun sceneAction(action: Int): String {
    when (action) {
        0 -> return resetScene()
        1 -> { if (scene.numEntities < 48) addParticle(); note = "创建实体:实体总数 ${scene.numEntities}" }
        2 -> { val entity = scene.family { all(Position) }.firstOrNull(); if (entity != null) scene -= entity; note = "删除实体:Family 自动同步" }
        3 -> {
            val entity = scene.family { all(Position) }.firstOrNull()
            if (entity != null) with(scene) {
                val moving = entity.getOrNull(Velocity) != null
                entity.configure { if (moving) it -= Velocity else it += Velocity(12f, 8f) }
                note = if (moving) "移除 Velocity:这个实体退出运动 Family" else "补回 Velocity:这个实体重新开始运动"
            }
        }
        4 -> { scene.update(0.05f); frames++ }
        5 -> { scene.removeAll(); note = "清空实体:World 与 Family 都归零" }
    }
    return sceneSnapshot()
}
fun sceneSnapshot(): String {
    val family = scene.family { all(Position, Velocity) }
    val items = mutableListOf<String>()
    scene.forEach { e ->
        val p = e[Position]
        items += "{\"id\":${e.id},\"x\":${p.x},\"y\":${p.y},\"moving\":${e.getOrNull(Velocity) != null}}"
    }
    return "{\"entities\":${scene.numEntities},\"moving\":${family.numEntities},\"frames\":$frames,\"note\":\"$note\",\"items\":[${items.joinToString(",")}] }"
}
fun runChecks(): String {
    val reports = mutableListOf<String>()
    var passed = 0
    fun verify(label: String, predicate: Boolean) {
        if (predicate) passed++
        reports += "{\"name\":\"$label\",\"ok\":$predicate}"
    }
    try {
        val w = newWorld()
        val movers = w.family { all(Position, Velocity) }
        verify("空 World 与 Family", w.numEntities == 0 && movers.numEntities == 0)
        val moving = w.entity { it += Position(10f, 20f); it += Velocity(4f, 2f) }
        val still = w.entity { it += Position(40f, 60f) }
        verify("创建与 Family 过滤", w.numEntities == 2 && movers.numEntities == 1)
        w.update(0.5f)
        with(w) {
            verify("System 使用 deltaTime", abs(moving[Position].x - 12f) < 0.001f && abs(moving[Position].y - 21f) < 0.001f)
            verify("无速度实体保持位置", still[Position].x == 40f && still[Position].y == 60f)
            moving.configure { it -= Velocity }
            verify("移除组件更新 Family", movers.numEntities == 0)
            w.update(0.5f)
            verify("退出 Family 后停止运动", abs(moving[Position].x - 12f) < 0.001f)
            moving.configure { it += Velocity(4f, 2f) }
            verify("补回组件重新匹配", movers.numEntities == 1)
        }
        w -= still
        verify("删除实体更新 World", w.numEntities == 1)
        val snapshot = w.snapshot()
        w.removeAll()
        verify("清空同步所有查询", w.numEntities == 0 && movers.numEntities == 0)
        w.loadSnapshot(snapshot)
        verify("加载内存快照恢复实体", w.numEntities == 1 && movers.numEntities == 1)
        movers.forEach { e -> w -= e }
        verify("遍历时删除实体", w.numEntities == 0 && movers.numEntities == 0)
        repeat(100) { w.entity { it += Position(1f, 2f); it += Velocity(1f, 1f) } }
        verify("扩容创建 100 个实体", w.numEntities == 100 && movers.numEntities == 100)
        w.dispose()
        verify("dispose 清空实体", w.numEntities == 0)
    } catch (error: Throwable) {
        reports += "{\"name\":\"异常:${error::class.simpleName}\",\"ok\":false}"
    }
    return "{\"passed\":$passed,\"total\":${reports.size},\"checks\":[${reports.joinToString(",")}] }"
}

2. 一个小球停下,实体数量并没有少

最值得观察的是"切换首个实体速度"。按下后,画布中一个小球变成橙色并停住,实体总数仍为八,运动 Family 变成七。这两个数放在一起,就能区分删除实体与移除组件。

代码通过 entity.configure 移除 Velocity,Position 保留下来,所以页面还能画出这个实体。MotionSystem 的查询条件已经不满足,下次更新自然不会处理它。再次按下按钮会补回 Velocity,运动 Family 恢复为八;示例补回的是预设速度,之前被移除的速度数值没有另行保存。

这些变化都在最终安装包上试过。MatePad Edge arm64 真机与 x86_64 模拟器分别完成新增、删除、移除速度、补回速度、暂停、清空和重置。下面依次是真机与模拟器的连续录制,坐标和 Family 数量由设备上的 Fleks 返回。

暂停与移除速度也有区别。暂停按钮停止页面触发更新,所有实体和组件仍留在 World 中;切换速度则改变指定实体的组件。清空会让实体数和 Family 数一起归零,重置会重新建立八个实体,但不会顺便改变页面当前的播放状态。把这些操作分开,可以避免一个"重置"按钮同时承担太多含义。

3. 让鸿蒙页面拿到这一份 World

场景逻辑集中后,适配工作沿调用链往外补。原来的 src/commonMain 保留 Fleks 实现;新增的 harmony/src/ohosMain/kotlin/FleksBridge.kt 放演示入口;原仓 example/harmony 放可运行的鸿蒙程序。这样的目录安排可以同时找到共享逻辑、平台接入和示例,后续改组件时也容易判断该改哪一层。

构建环境直接参考 CPF-KMP-CMP 官方快速开始。本次使用上游版本配置为 2.15 的源码,鸿蒙 Kotlin/Native 为 2.2.21-1.1.0。上游原构建配置声明 Kotlin 2.4.10,因此鸿蒙版本与依赖放到独立的 harmony/build.gradle.kts 中,原仓版本配置继续保留。

配置分别加入 ohosArm64、ohosX64,把 atomicfu 和 serialization 对齐到社区提供的鸿蒙产物。双目标都需要对应的 KLIB,不能直接用其他平台的编译产物代替。

复制代码
plugins {
    kotlin("multiplatform") version "2.2.21-1.1.0"
    kotlin("plugin.serialization") version "2.2.21-1.1.0"
}
kotlin {
    listOf(ohosArm64(), ohosX64()).forEach { target ->
        target.binaries.sharedLib { baseName = "fleks" }
    }
    sourceSets {
        commonMain {
            kotlin.srcDir("../src/commonMain/kotlin")
            dependencies {
                implementation("org.jetbrains.kotlinx:atomicfu:0.31.0-1.0.0")
                implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.1-1.0.0")
                implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.1-1.0.0")
            }
        }
        commonTest { kotlin.srcDir("../src/commonTest/kotlin"); dependencies { implementation(kotlin("test")) } }
        val ohosMain by creating { dependsOn(commonMain.get()); kotlin.srcDir("src/ohosMain/kotlin") }
        getByName("ohosArm64Main").dependsOn(ohosMain)
        getByName("ohosX64Main").dependsOn(ohosMain)
    }
}

实际构建由 harmony/scripts/build-native.py 完成:共享源码与示例入口编译成 KLIB,再链接动态库并生成 C 头文件。Gradle 配置定义了目标与依赖,本例使用 konanc 命令行生成双架构产物。应用使用 DevEco Studio 6.1.1.300 和 API 24 SDK,两台设备也都是 API 24,尚未验证 API 26。

example/harmony 中的 fleks_napi.cpp 调用生成头文件里的入口,CMake 根据架构链接 libfleks.so。桥接层把字符串复制成 ArkTS 可接收的值,随后调用 DisposeString 释放 Kotlin/Native 返回的字符串;周期更新会反复经过这里,资源释放需要与每次调用配对。

复制代码
#include "napi/native_api.h"
#include "fleks_api.h"
static napi_value CopyResult(napi_env env, const char* text) {
  napi_value value; napi_create_string_utf8(env,text?text:"{}",NAPI_AUTO_LENGTH,&value);
  if(text) fleks_symbols()->DisposeString(text); return value;
}
static napi_value Reset(napi_env env,napi_callback_info info) { return CopyResult(env,fleks_symbols()->kotlin.root.harmony.fleks.resetScene()); }
static napi_value Action(napi_env env,napi_callback_info info) {
  size_t argc=1; napi_value args[1]; napi_get_cb_info(env,info,&argc,args,nullptr,nullptr);
  int32_t value=0;
  if(argc!=1 || napi_get_value_int32(env,args[0],&value)!=napi_ok) { napi_throw_type_error(env,nullptr,"Expected an action number");return nullptr; }
  return CopyResult(env,fleks_symbols()->kotlin.root.harmony.fleks.sceneAction(value));
}
static napi_value Check(napi_env env,napi_callback_info info) { return CopyResult(env,fleks_symbols()->kotlin.root.harmony.fleks.runChecks()); }
static napi_value Init(napi_env env,napi_value exports) {
  napi_property_descriptor methods[]={
    {"reset",nullptr,Reset,nullptr,nullptr,nullptr,napi_default,nullptr},
    {"action",nullptr,Action,nullptr,nullptr,nullptr,napi_default,nullptr},
    {"runChecks",nullptr,Check,nullptr,nullptr,nullptr,napi_default,nullptr}};
  napi_define_properties(env,exports,3,methods);return exports;
}
static napi_module module={1,0,nullptr,Init,"fleksbridge",nullptr,{0}};
extern "C" __attribute__((constructor)) void RegisterFleks(){napi_module_register(&module);}

页面每隔 50 毫秒请求一次更新,用快照里的坐标绘制 Canvas。实体位置按百分比保存,画布尺寸决定最终显示坐标,组件移除后快照中的 moving 也随之改变。界面读取这些字段,就能同时画出小球颜色、数量和提示信息。

这里使用固定时间步,主要观察组件变化后的运动状态,尚未测量帧率和延迟。页面退出时关闭定时器;暂停时停止推进 World,但按钮仍可以修改实体。页面代码如下。

复制代码
import native from 'libfleksbridge.so';

interface Particle { id: number; x: number; y: number; moving: boolean; }
interface Scene { entities: number; moving: number; frames: number; note: string; items: Particle[]; }
interface Check { name: string; ok: boolean; }
interface Checks { passed: number; total: number; checks: Check[]; }

@Entry
@Component
struct Index {
  @State scene: Scene = { entities: 0, moving: 0, frames: 0, note: '', items: [] };
  @State playing: boolean = true;
  @State checked: string = '未检查';
  @State details: string = '';
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  private timer: number = -1;
  private canvasWidth: number = 800;
  private canvasHeight: number = 360;

  aboutToAppear(): void {
    this.scene = JSON.parse(native.reset()) as Scene;
    this.timer = setInterval(() => {
      if (this.playing) { this.scene = JSON.parse(native.action(4)) as Scene; this.draw(); }
    }, 50);
  }
  aboutToDisappear(): void { if (this.timer >= 0) clearInterval(this.timer); }
  private action(value: number): void { this.scene = JSON.parse(native.action(value)) as Scene; this.draw(); }
  private draw(): void {
    const c = this.ctx;
    c.fillStyle = '#111B2F'; c.fillRect(0, 0, this.canvasWidth, this.canvasHeight);
    c.strokeStyle = '#243047'; c.lineWidth = 1;
    for (let x = 0; x < this.canvasWidth; x += 40) { c.beginPath(); c.moveTo(x, 0); c.lineTo(x, this.canvasHeight); c.stroke(); }
    for (let y = 0; y < this.canvasHeight; y += 40) { c.beginPath(); c.moveTo(0, y); c.lineTo(this.canvasWidth, y); c.stroke(); }
    for (const item of this.scene.items) {
      const x = item.x * this.canvasWidth / 100; const y = item.y * this.canvasHeight / 100;
      c.fillStyle = item.moving ? '#70DBC0' : '#FFBD75';
      c.beginPath(); c.arc(x, y, 12, 0, Math.PI * 2); c.fill();
      c.fillStyle = '#D5DFEE'; c.font = '12px sans-serif'; c.fillText(`${item.id}`, x + 16, y + 4);
    }
  }
  build() {
    Scroll() {
      Column({ space: 18 }) {
        Row() {
          Column({ space: 8 }) {
            Text('给实体装上速度').fontSize(32).fontWeight(FontWeight.Bold).fontColor('#142943')
            Text('Fleks ECS · Kotlin Multiplatform / HarmonyOS').fontSize(14).fontColor('#748397')
          }.alignItems(HorizontalAlign.Start).layoutWeight(1)
          Text('World → Family → System').fontSize(14).fontColor('#287B70')
        }.width('100%')
        Row({ space: 30 }) {
          Text(`实体 ${this.scene.entities}`).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#142943').id('entityCount')
          Text(`运动 Family ${this.scene.moving}`).fontSize(20).fontColor('#218570').id('movingCount')
          Text(`更新 ${this.scene.frames}`).fontSize(14).fontColor('#758298').id('frameCount')
        }.width('100%')
        Canvas(this.ctx).height(360).width('100%').borderRadius(18).id('ecsCanvas')
          .onReady(() => this.draw())
          .onAreaChange((oldArea: Area, newArea: Area) => { this.canvasWidth = Number(newArea.width); this.canvasHeight = Number(newArea.height); this.draw(); })
        Row({ space: 9 }) {
          Button(this.playing ? '暂停' : '继续').id('toggle').onClick(() => this.playing = !this.playing)
          Button('新增实体').id('add').onClick(() => this.action(1))
          Button('删除实体').id('remove').onClick(() => this.action(2))
          Button('切换首个实体速度').id('velocity').onClick(() => this.action(3))
          Button('清空').id('clear').onClick(() => this.action(5))
          Button('重置').id('reset').onClick(() => this.action(0))
        }.width('100%')
        Text(this.scene.note).fontSize(15).fontColor('#426277').width('100%').id('sceneNote')
        Text('绿色有 Position + Velocity;橙色只保留 Position。界面读取原库状态,不在 ArkTS 里更新坐标。')
          .fontSize(13).fontColor('#738296').width('100%')
        Row() {
          Text(`原生断言:${this.checked}`).fontSize(16).fontColor('#294A5D').layoutWeight(1).id('checkStatus')
          Button('运行 13 项检查').id('checks').onClick(() => {
            const result = JSON.parse(native.runChecks()) as Checks;
            this.checked = `${result.passed}/${result.total}`;
            this.details = result.checks.map((item: Check) => `${item.ok ? 'PASS' : 'FAIL'} ${item.name}`).join('\n');
          })
        }.width('100%')
        if (this.details.length > 0) {
          Text(this.details).fontSize(13).fontColor('#294A5D').lineHeight(21).fontFamily('monospace').width('100%')
            .padding(18).backgroundColor('#E8F1EF').borderRadius(12).id('checkDetail')
        }
      }.padding(28).width('100%').constraintSize({ maxWidth: 1150 })
    }.width('100%').height('100%').backgroundColor('#F2F6FA')
  }
}

4. 场景结束后再检查一次

只看小球在动,还不足以判断 ECS 适配是否正确。设备端另外创建了一份 World,检查空集合、Family 筛选、时间步更新、静止实体、组件移除与恢复、实体删除、清空、快照加载、遍历中删除、百个实体扩容以及 dispose。真机与模拟器均返回 13/13,这份检查 World 不会清空正在展示的场景。

其中快照检查使用内存对象:先保存快照,清空 World,再加载回来,确认实体与查询数量恢复。序列化依赖参与了构建,但这一步没有覆盖 JSON 编解码和文件持久化。如果后续要保存游戏进度,还需要把组件序列化、版本变化和恢复失败分别补进测试。

这个示例的收获很具体:创建和删除可以对上实体数量,速度组件的变化可以对上 Family,系统更新可以对上坐标,清空和释放也有独立断言。共享源码、双架构产物、桥接入口和 example/harmony 都留下了对应位置,读者可以从任意一个按钮往回追到实际库调用。

继续扩展时,可以先加一类组件或一个系统,保留同样明确的预期结果。实体规模、序列化和系统执行顺序都值得单独验证;性能比较则需要固定数据规模与测量方式。Fleks 的 API 和原有测试可继续查阅 上游仓库,鸿蒙目标及依赖维护参考 CPF-KMP-CMP。

项目源码:harmonyos-crossplatform-libs。本文示例位于 libraries/fleks,构建步骤见 适配说明。

相关推荐
Sunny_GMF4 小时前
Markdown 导出 HTML 实战:2.6MB 编辑器快照如何瘦身 71%(DOM 净化 + CSSOM 运行时裁剪)
编辑器·html·ai编程·harmonyos
m0_738185824 小时前
Flutter 鸿蒙化实战:the_apple_sign_in 适配 OpenHarmony,Apple 登录
flutter·华为·harmonyos·鸿蒙
神奇的代码在哪里4 小时前
原生鸿蒙 GBC 模拟器开发实录:用 Anime4K 把 160×144 画面实时放大到 1080P
harmonyos·鸿蒙·超分辨率·gbc模拟器·anime4k
m0_738185825 小时前
Flutter 鸿蒙化实战:umeng_analytics_plugin 适配 OpenHarmony,友盟统计
flutter·华为·harmonyos·鸿蒙
todoitbo5 小时前
把 XAMPP 搬到鸿蒙 PC:在鸿蒙 PC 上跑 Apache httpd 2.4.69实战项目
华为·apache·harmonyos·鸿蒙·开源鸿蒙·鸿蒙pc
JoyCong19985 小时前
即插即用,组合键可用:ToDesk 4820版本强化鸿蒙远控键鼠能力
华为·智能手机·harmonyos·远程工作·ipad
程序猿追5 小时前
漏字、换词序、空查询:用 FuzzyKot 做一次鸿蒙搜索交互排查
华为·交互·harmonyos
懿路向前5 小时前
HarmonyOS 粒子特效实战:Particle 挂载 ≠ 显示:emitRate/count 真实语义与同帧黑缝(HarmonyOS ArkUI)
华为·harmonyos
贾伟康6 小时前
【化学实验室|02】HarmonyOS ArkTS 实验结果载荷实战:用强类型模型隔离页面参数与结果展示
harmonyos·arkts·canvas·arkui·化学实验