开发调试踩坑记录

记录项目开发过程中遇到的典型问题和调试经验,帮助后续高效定位。


1. ArkUI @Prop 深拷贝丢失 Record 属性

现象

卡片渲染时组件参数不完整,下划线前缀的属性(如 _componentPath、__ascfInstanceId__)丢失。

根因

ArkUI @Prop 装饰器会对传入的数据做深拷贝。Record<string, Object> 类型的字段在深拷贝过程中,非字面量赋值的属性(通过 obj[key] = value 动态设置的)可能被丢弃或变为不可枚举。

解决方案

遵循已有的 static singleton 模式(参考 commit e8abf71)。在 @Prop 边界之前(aboutToAppear 中)将数据存入 static Map,build() 时通过 key 取回:

// 错误:@Prop 深拷贝会丢失属性
@Prop cardData: CardRenderData = { params: {} }

// 正确:static store 绕过 @Prop
const staticStore: Map<number, Record<string, Object>> = new Map()
// aboutToAppear: staticStore.set(key, realParams)
// build: staticStore.get(key) ?? fallback

相关文件

  • ascf-agent/agent-runtime/src/main/ets/ui/FlexCardView.ets

2. BinaryWriter 嵌套 Map 序列化损坏

现象

LoadInstance 反序列化 buffer 数据与实际 Map 结构不匹配,C++ Deserializer 读取 key-value 时错位,导致 params 丢失或损坏。日志:

ReadObject(VALUE) FAILED at number=2 key=drink_recommended-drinks_1

根因(多层嵌套)

当 jsParams 结构为 Map { "params": Map { "params": "<JSON_STRING>" } }(两层嵌套 Map),BinaryWriter 序列化时产生错误的二进制结构。C++ Deserializer 在解析时 key-value 错位——将 value(如 drink_recommended-drinks_1)误读为 key。

详细原因:Serializer.writeJSObject() 递归调用 writeValue() 处理嵌套 Map,但 BinaryWriter 的 tag-length-value 格式在处理嵌套结构时存在 offset 计算 bug。

诊断方法

  1. 在 C++ Deserializer::ReadObjectProperties 中打印每个 key 和对应的 value 类型
  2. 查看 LoadInstance 的 hex buffer:hdc shell "hilog -x" | grep "DEBUG-LoadInstance.*buf"
  3. 对比 ArkTS 侧构造的 Map 结构与 buffer hex 内容,定位错位点

解决方案(两条互补路径)

路径 A:扁平化 params(推荐) 在 FlexUIEngine.loadCard() 中,将每个 key-value 直接设为 jsParams 的 entry,不对 params 做二次包装:

// 错误:嵌套 Map
jsParams.set('params', JSON.stringify(cleanParams));  // Map { params: String }

// 正确:扁平化,每个 key 独立
for (const key of keys) {
    if (key !== '_componentPath') {
        const val = params[key];
        if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
            jsParams.set(key, val as HippyValue);
        } else {
            jsParams.set(key, JSON.stringify(val) as HippyValue);
        }
    }
}

路径 B:Bootstrap 直接读取扁平 params(JS 侧兜底) 无论 ArkTS 侧用哪种格式,bootstrap 兼容两种结构:

// 兼容扁平格式 params = { __ascfInstanceId__, structuredContent, ... }
// 兼容嵌套格式 params = { name, id, params: { ... } }
var cardParams = (params && params.params) ? params.params : (params || {});

相关文件

  • flexui-engine/.../serialization/Serializer.ets — 序列化逻辑(不建议修改)
  • flexui-engine/.../flexui/FlexUIEngine.ets — 参数转换层(路径 A)
  • toolkit/ascf-toolkit/lib/aimode-compiler/harmony-entry-template.js — JS bootstrap(路径 B)

3. ParseJson 对 PendingException 的 FATAL 崩溃

现象

FOOTSTONE_DCHECK(status == JSVM_OK) 在 debug build 中触发 FATAL 终止进程。

根因

JSHVM::ParseJson 只处理了 JSVM_GENERIC_FAILURE,未处理 JSVM_PENDING_EXCEPTION。当前序操作在 JS VM 中抛出了未捕获异常(如 LoadInstance param error),后续 OH_JSVM_JsonParse 返回非 OK 状态,触发 FOOTSTONE_DCHECK fatal。

解决方案

将 FOOTSTONE_DCHECK 改为正常的错误处理逻辑:

auto status = OH_JSVM_JsonParse(env_, jsh_string_value->GetValue(), &result);
if (status == JSVM_GENERIC_FAILURE) {
    FOOTSTONE_LOG(ERROR) << "JSHVM::ParseJson error, json: " << json;
    return nullptr;
}
// 新增:处理其他非 OK 状态(如 PENDING_EXCEPTION)
if (status != JSVM_OK) {
    FOOTSTONE_LOG(ERROR) << "JSHVM::ParseJson status=" << (int)status;
    return nullptr;
}
return std::make_shared<JSHCtxValue>(env_, result);

相关文件

  • flexui-engine/driver/js/src/vm/jsh/jsh_vm.cc

4. callJsModule 事件与 FlexView 组件不在同一 JS Scope

现象

callJsModule("__ascfCallbacks", "onCardEvent", ...) 发送了正确的数据(日志可见 structuredContent 含 3 个 items),但组件侧的 NotificationType.Result 回调从未触发。组件日志显示 addListener on unknown instance ""。

根因

Hippy 引擎为每个 FlexView 创建独立的 JS 执行上下文(Scope)。callJsModule 调用通过 ArkTS → C++ → 主 Scope 的 hippyBridge 函数派发,目标 globalThis.__ascfCallbacks 属于主 Scope。但 FlexView 的 .pack.js 运行在子 Scope 中,其 __ascfCallbacks 是不同的对象。

时序图:

ArkTS: allocate() → callJS('onCardEvent', [id, 'result', dataJson])
  → C++: CallJs(MAIN_SCOPE) → hippyBridge → globalThis.__ascfCallbacks.onCardEvent(...)
                                                      ↑
                                              主 Scope 的 __ascfCallbacks
                                                      ✗ 子 Scope 的 instanceEventBus 不在这个 Scope

诊断方法

  1. 确认 __ascfCallbacks 存在:bootstrap 添加 console.log(typeof globalThis.__ascfCallbacks)
  2. 对比 callJsModule 时间戳与 bootstrap RUN 时间戳,确认到达顺序
  3. 检查组件日志中是否出现 [modelContext] onInstanceCreate / registerInstance / onCardEvent

解决方案

不依赖 callJsModule 传递初始数据。改为两条路径:

  1. 通过 LoadInstance params 传递(扁平化,参考 Pitfall #2)——数据随组件创建一起到达
  2. Bootstrap 直接注入全局变量——在 run(params) 中提取 params 并设置 globalThis.__ascfResultData__,组件 created() 中直接读取
// bootstrap: run(params)
globalThis.__ascfResultData__ = parsedParams;
globalThis.__ascfInstanceId__ = instId;

// component: created()
var data = globalThis.__ascfResultData__;
if (data) { this.setData({ items: data._meta.viewItems.slice(0, 3) }); }

相关文件

  • flexui-engine/driver/js/src/js_driver_utils.cc — CallJs / LoadInstance scope 管理
  • flexui-engine/driver/js/src/scope.cc — Scope::LoadInstance
  • ascf-agent/agent-runtime/src/main/ets/modelcontext/InstanceManager.ets — 事件发送
  • toolkit/ascf-toolkit/lib/aimode-compiler/harmony-entry-template.js — bootstrap 全局注入

5. callJsModule 事件在 .pack.js 加载之前到达

现象

callJsModule("onInstanceCreate", ...) 日志时间戳早于 runScriptFromUri(.pack.js 加载完成)700ms+。调用到达时 __ascfCallbacks 尚未定义。

根因

ArkTS 侧 InstanceManager.allocate() → callJS('onInstanceCreate', ...) 是同步调用,立即通过 C++ bridge 投递到 JS 线程。但 FlexView 的 JS bundle 加载(runScriptFromUri)是异步的(需要从 rawfile 读取并解析 500KB+ 的 .pack.js)。

时序:

t=0:    allocate() → callJS('onInstanceCreate', ...) → 投递到 JS task queue
t=0:    FlexView 创建 → loadCard → loadModule → 异步加载 .pack.js
t=700ms: runScriptFromUri 完成 → bootstrap 执行 → __ascfCallbacks 定义
t=700ms: JS task queue 处理 callJS → __ascfCallbacks 已存在 ← 但 Scope 不对!

解决方案

采用 InstanceManager 的 pending events 机制,延迟到组件加载完成后再 dispatch:

// allocate() 和 dispatchResult() 将事件存入 pendingEvents
this.pendingEvents.set(instanceId, [
    ['onInstanceCreate', [instanceId]],
    ['onCardEvent', [instanceId, 'result', resultJson]],
]);

// FlexCardView.onViewReady 时 flush
AscfAIModule.sInstanceManager.flushEvents(instanceId);

相关文件

  • ascf-agent/agent-runtime/src/main/ets/modelcontext/InstanceManager.ets — flushEvents
  • ascf-agent/agent-runtime/src/main/ets/ui/FlexCardView.ets — onViewReady 触发 flush

6. McpToolExecutor 未调用 Skill API,数据来源错误

现象

卡片渲染显示"没有匹配的饮品"(空状态)。onCardEvent('result', data) 中的 data 只有:

{"tool":"getRecommendedDrinks","arguments":{},"_componentPath":"..."}

缺少 structuredContent、_meta、items 等业务数据。

根因

McpToolExecutor.execute() 目前返回硬编码 mock 数据,而不是调用真正的 Skill API 实现。正确的流程应该是:

LLM calls mcp__getRecommendedDrinks
  → ToolExecutor (source='mcp')
    → McpToolExecutor.execute()
      → SkillApiBridge.executeApi(skillPath, apiName, args)   ← 当前缺失!
        → callJS('executeSkillApi', [...])
          → JS: skillApiRegistry.execute(skillPath, apiName, args)
            → 执行 apis/getRecommendedDrinks.js(真实业务逻辑)
              → read data/seed.js → filter → return { structuredContent, _meta }

Skill API 的注册与执行流程:

  1. activate_skill tool → SkillActivator.activate(skillPath) → 在 JS 引擎执行 index.js
  2. index.js 调用 skill.registerAPI('getRecommendedDrinks', getRecommendedDrinks) 注册 handler
  3. Handler 存储在 modelContext.ts 的 skillApiRegistry(JS 侧)
  4. 后续 tool 调用应通过 SkillApiBridge.executeApi() → callJS('executeSkillApi', ...) 触发 handler
  5. Handler 执行结果通过 callNative('onSkillApiResult', ...) 返回 ArkTS 侧

解决方案

McpToolExecutor 需要持有 SkillApiBridge 的引用和 skillPath,在 execute() 中调用真正的 API:

class McpToolExecutor implements ToolExecutor {
    private entry: McpApiEntry
    private skillPath: string
    private apiBridge: SkillApiBridge

    async execute(args: string): Promise<string> {
        const parsedArgs = JSON.parse(args)
        // 调用真正的 Skill API(JS 侧执行)
        const apiResult = await this.apiBridge.executeApi(this.skillPath, this.entry.name, parsedArgs)
        const resultObj = JSON.parse(apiResult)
        // 注入 _componentPath(CardRenderer 需要)
        if (compPath) { resultObj._componentPath = compPath }
        return JSON.stringify(resultObj)
    }
}

注册时传入依赖:修改 McpConfigLoader.registerToolEntry() 接收 skillPath 和 apiBridge:

McpConfigLoader.registerToolEntry(entry, toolRegistry, mcpConfigDir, apiBridge)
// mcpConfigDir 如 'skills/drink-skill'(从 mcp.json 路径推导)

注意:skillPath 需要与 index.js 中 createSkill('skills/drink-skill') 的路径一致。

相关文件

  • ascf-agent/agent-runtime/src/main/ets/mcp/McpConfigLoader.ets — McpToolExecutor + registerToolEntry
  • ascf-agent/agent-runtime/src/main/ets/modelcontext/SkillApiBridge.ets — executeApi / handleResult
  • ascf-agent/agent-runtime/src/main/ets/AgentRuntime.ets — loadConfig 传入 apiBridge
  • flexui-engine/driver/js/packages/hippy-ascf/src/modelContext.ts — skillApiRegistry.execute
  • demos/ascf-ai-demo/ascf/ascf_src/skills/drink-skill/index.js — API 注册 (createSkill + registerAPI)
  • demos/ascf-ai-demo/ascf/ascf_src/skills/drink-skill/apis/getRecommendedDrinks.js — API 实现

⚠ 注意 skillPath 不一致:loadConfig 从 configPath(如 ascf-ai-demo/skills/drink/mcp.json)推导 skillPathFromConfig = ascf-ai-demo/skills/drink。但 index.js 中 createSkill('skills/drink-skill') 使用的是不同路径。当前推导可能与实际注册路径不匹配,导致 SkillApiBridge.executeApi() 找不到 handler。需要后续统一或通过 SkillDiscovery 映射。


7. has 全局变量在 FlexView Scope 中不存在

现象

组件 created() 中抛出 ReferenceError: has is not defined。日志:

[recommended-drinks] has is not defined

根因

组件代码直接引用 has.modelContext.getContext(this)(全局变量),但 has 来自 @hippy/ascf 的 ES module export,不会自动暴露为 FlexView Scope 的全局变量。主 Scope 中 Hippy 引擎注入了 has,但子 Scope 没有。

解决方案

在 bootstrap 模板中显式将 has 设为全局变量:

var _hippyAscf = require('@hippy/ascf');
if (_hippyAscf.has) { globalThis.has = _hippyAscf.has; }

注意:@hippy/ascf 的 webpack resolve alias 必须指向 flexui fork(flexui-engine/driver/js/packages/hippy-ascf/src),因为上游 Hippy repo 的 has.ts 不包含 modelContext 导出。

相关文件

  • toolkit/ascf-toolkit/lib/aimode-compiler/harmony-entry-template.js — bootstrap 全局注入
  • toolkit/ascf-toolkit/lib/aimode-compiler/webpack-config-factory.js — resolve alias
  • demos/ascf-demo/webpack.config.js — 主 bundle 的 resolve alias

8. HAR 增量构建缓存导致的"改了代码不生效"

现象

修改了 FlexUIEngine.ets(位于 flexui-engine/framework/ohos),重新编译 hippy HAR 成功,但 agent HAP 部署后行为不变。

根因

  1. hvigorw 增量构建可能跳过已编译的模块(显示 UP-TO-DATE)
  2. ohpm install 不会自动更新已缓存的 HAR(需要删除 oh_modules/.ohpm 重装)
  3. hvigorw 不检测 rawfile 文件变化,需 clean 后重新 assembleHap

解决方案

完整的强制重编流程:

# 1. 清理 + 重编 hippy HAR
rm -rf flexui-engine/framework/ohos/build
hvigorw --mode module -p module=hippy@default assembleHar

# 2. 清理 agent 缓存 + 重装依赖
rm -rf ascf-agent/oh_modules/.ohpm ascf-agent/agent-runtime/build ascf-agent/entry/build
ohpm install

# 3. 重编 agent HAP
hvigorw --mode module -p module=entry@default assembleHap

验证 HAR 是否包含修改:

unzip -p entry-default-signed.hap "resources/rawfile/..." | grep "关键代码片段"

相关文件

  • flexui-engine/framework/ohos/build/ — HAR 输出
  • ascf-agent/oh_modules/.ohpm/ — ohpm 缓存

9. ArkTS 语法限制

9a. build() 中只能写 UI 组件语法

错误:

build() {
    const storeParams = this.paramsFromStore()  // ❌ 不允许变量声明
    Logger.info(TAG, '...')                      // ❌ 不允许函数调用
    FlexView({ ... })
}

正确:所有非 UI 逻辑移到 aboutToAppear() 或回调中。如需日志,使用 .onAppear() 回调。

9b. 禁止无类型对象字面量 (arkts-no-untyped-obj-literals)

错误:

return { drinkId: 1, name: '拿铁', price: 28 }  // ❌

正确:通过 JSON.stringify/parse 绕过类型检查:

const jsonStr = '{"drinkId":1,"name":"拿铁","price":28}'
return JSON.parse(jsonStr) as Record<string, Object>

9c. 类型转换需要经过 unknown

错误:

const merged: Record<string, Object> = result as Record<string, Object>  // ❌

正确:

// ToolExecResult → Object → JSON.stringify → JSON.parse → Record<string, Object>
const mergedStr = JSON.stringify(result as Object)
const merged: Record<string, Object> = JSON.parse(mergedStr) as Record<string, Object>

9d. typeof 禁止用于 any/unknown 类型

错误:

const val = params[key]  // val 类型为 ESObject | undefined ≈ any
if (typeof val === 'string') { ... }  // ❌

正确:先显式转换类型:

const val: Object = params[key] as Object
const valType: string = typeof val
if (valType === 'string') { ... }

10. 端到端渲染数据流(完整版)

Tool Call (LLM)
  │
  ▼
McpToolExecutor.execute() → 返回 { structuredContent, _meta, _componentPath }   ← 必须有业务数据!
  │
  ▼
AgentRuntime.run().TOOL_EXECUTING
  ├── CardRenderer.resolve() → 查找 componentPath → CardRegistryEntry
  ├── InstanceManager.allocate() → 创建 instanceId + 事件入队 (pendingEvents)
  ├── InstanceManager.dispatchResult() → result 事件入队
  │
  ▼
FlexCardView
  ├── @Prop 边界 ← static store 绕过深拷贝
  ├── FlexView → HippyEngine.loadModule()
  │
  ▼
FlexUIEngine.loadCard() → jsParams 扁平化   ← 关键转换点!
  │
  ▼
HippyBridgeManagerImpl.loadInstance()
  ├── BinaryWriter 序列化 {
  │     name: String,
  │     id: Number,
  │     params: {                                   ← 单层 Map,无嵌套!
  │       __ascfInstanceId__: String,               ← 必须在此
  │       structuredContent: String (JSON),
  │       _meta: String (JSON),
  │       __instanceName__: String,
  │       __instanceId__: Number,
  │     }
  │   }
  │
  ▼
C++ JsDriverUtils::LoadInstance
  ├── Hermes: buffer → UTF-16 string → ParseHippyValue → createFromJsonUtf8
  │   ⚠ 注意:Hermes 路径期望 JSON,不是 binary 格式!
  │   ⚠ getSerializerBufferBinary() vs getSerializerBufferJson() 不匹配
  │
  ▼
JS: appRegister[name].run(params)
  │
  ▼
Harmony Entry Bootstrap
  ├── require('@hippy/ascf') → globalThis.has = _hippyAscf.has   ← 必须设置!
  ├── cardParams = params.params || params                       ← 兼容 flat/nested
  ├── globalThis.__ascfResultData__ = parsedParams               ← 直接数据注入
  ├── globalThis.__ascfInstanceId__ = instId
  │
  ▼
Component created()
  ├── globalThis.__ascfResultData__ → setData(items)   ← 主要数据路径
  ├── has.modelContext.getContext(this).on(Result, ...) → 备用事件路径
  │
  ▼
Glass-easel setData → template re-render → NativeBackend.flush → HippyBridge updateNode → C++ 渲染

关键检查点(按顺序):

  1. McpToolExecutor — 是否返回了 structuredContent+_meta
  2. @Prop 边界 — 数据是否通过 static store 绕过
  3. FlexUIEngine.loadCard — Object → HippyMap 转换是否扁平
  4. BinaryWriter 序列化 — buffer hex 是否与构造的 Map 一致
  5. C++ Hermes 路径 — buffer 是 binary 还是 JSON 格式
  6. Bootstrap — globalThis.has、__ascfResultData__ 是否设置
  7. Component — created() 中数据来源是否正确

11. 调试工具链速查

构建与部署

# Agent 完整 dev 循环
./build.sh dev-agent

# 仅构建 hippy HAR(FlexUIEngine 修改后)
rm -rf flexui-engine/framework/ohos/build
hvigorw --mode module -p module=hippy@default -p product=default -p buildMode=debug assembleHar

# Agent 强制重编(HAR 修改后)
rm -rf ascf-agent/oh_modules/.ohpm ascf-agent/agent-runtime/build ascf-agent/entry/build
ohpm install && hvigorw --mode module -p module=entry@default assembleHap

# 重编 .pack.js 组件 bundles(bootstrap/组件修改后)
node -e "var w=require('webpack');var {createAiModeCompilerConfig}=require('./toolkit/ascf-toolkit/lib/aimode-compiler/webpack-config-factory');w(createAiModeCompilerConfig('demos/ascf-ai-demo/ascf/ascf_src','demos/ascf-ai-demo',{mode:'development'})[1],function(e,s){if(!s.hasErrors())console.log('OK')})"
cp -r demos/ascf-ai-demo/entry/src/main/resources/rawfile/skills ascf-agent/entry/src/main/resources/rawfile/ascf-ai-demo/

# 安装 + 启动
hdc install -r ascf-agent/entry/build/default/outputs/default/entry-default-signed.hap
hdc shell "aa force-stop com.atomicservice.5765880207854768701 && sleep 1 && aa start -a EntryAbility -b com.atomicservice.5765880207854768701"

日志查看

# JS console 日志(经过 ConsoleModule → hilog)
hdc shell "hilog -x -D 0x0001" | grep 'console_module' | grep 'recommended-drinks'

# C++ 日志(domain 0x0001, tag: hippy)
hdc shell "hilog -x -D 0x0001" | grep 'A00001.*hippy'

# LoadInstance buffer hex dump
hdc shell "hilog -x -D 0x0001" | grep 'DEBUG-LoadInstance.*buf'

# Bridge 调用日志(onInstanceCreate, onCardEvent)
hdc shell "hilog -x -D 0x0001" | grep 'callJsModule.*ascfCallbacks'

# 查看崩溃
hdc shell "hilog -x -D 0x0001" | grep -E 'FATAL|CHECK FAILED|SIGSEGV|LoadInstance param error'

添加诊断日志

ArkTS (Logger):

import { Logger } from '../utils/Logger'
const TAG = 'ComponentName'
Logger.info(TAG, 'msg=%{public}s count=%{public}d', strVal, numVal)

JS (console,经 ConsoleModule → hilog):

console.log('[prefix] message')
console.warn('[prefix] warning')
console.error('[prefix] error')

C++ (footstone):

#include "footstone/logging.h"
FOOTSTONE_LOG(INFO) << "message";     // release + debug
FOOTSTONE_DLOG(INFO) << "message";    // debug only
FOOTSTONE_LOG(ERROR) << "message";    // 错误

验证 HAP 内容

# 提取 pack.js 查看是否包含修改
unzip -p entry-default-signed.hap "resources/rawfile/ascf-ai-demo/skills/drink/components/recommended-drinks/index.pack.js" | grep "关键代码"

# 列出 HAP 中所有 rawfile
unzip -l entry-default-signed.hap | grep rawfile

常见构建陷阱

  • HAR 修改不生效 → rm -rf oh_modules/.ohpm && ohpm install
  • .pack.js 修改不生效 → 重编 webpack + 重编 HAP(hvigorw 不检测 rawfile 变化)
  • ArkTS 编译错误 10605038 → 对象字面量需要显式类型,用 JSON.parse(jsonStr) 绕过
  • ArkTS 编译错误 10905204 → build() 中不能写非 UI 语句,移到 aboutToAppear() 或回调
  • hvigorw 找不到 hvigor-config.json5 → 检查 CWD,必须在项目根目录或模块目录执行
  • 设备 ID 变化 → hdc list targets 查看,更新 build.sh 中的 DEVICE 变量

12. AGP 7.2.2 + Compose BOM 版本兼容性

现象

Demo APK 构建失败:checkDebugAarMetadata FAILED — requires compile against version 34。

根因

Compose BOM 2024.02.00 传递依赖了 activity:1.8.2、lifecycle:2.7.0、emoji2:1.4.0 等库,这些库的 AAR metadata 声明需要 compileSdkVersion >= 34,但项目使用 COMPILE_VERSION=33。

关键认知

  • compileSdkVersion 不需要 AGP 升级 —— AGP 只是构建工具,不会限制你能用哪个 SDK 平台编译
  • 限制条件只有一个:SDK platform 必须已安装($ANDROID_HOME/platforms/android-34)
  • AGP 7.2.2 可以编译 SDK 34,只要平台已安装

解决方案

路径 A(推荐):升级 COMPILE_VERSION 到 34

# flexui-engine/gradle.properties
COMPILE_VERSION=34

不需要升级 AGP,不需要升级 Kotlin,不需要改 NDK。

路径 B(备用):降级 Compose 依赖到与 SDK 33 兼容的版本

// 不用 BOM,手动指定版本
implementation 'androidx.compose.ui:ui:1.5.0'
implementation 'androidx.compose.material3:material3:1.1.0'
implementation 'androidx.activity:activity-compose:1.7.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1'

相关文件

  • flexui-engine/gradle.properties
  • flexui-engine/android-modules/agent-runtime/build.gradle

13. Compose API 版本差异

现象

编译错误:Unresolved reference: HorizontalDivider、Unresolved reference: VerticalDivider、Unresolved reference: TextAlign.Unspecified。

根因

HorizontalDivider / VerticalDivider 在 Material3 1.2.0(BOM 2024.x)中引入。降级到 BOM 2023.x 后这些 API 不存在。

解决方案

旧 API 替换
HorizontalDivider(...) Divider(...)
VerticalDivider(thickness, color, modifier) Box(Modifier.fillMaxHeight().width(thickness).background(color))
TextAlign.Unspecified TextAlign.Start(作为默认 fallback)

相关文件

  • agent-runtime/src/main/java/org/flexui/agent/ui/markdown/MarkdownRenderer.kt
  • agent-runtime/src/main/java/org/flexui/agent/ui/markdown/CodeFenceBlock.kt

14. Kotlin 属性命名与 JVM 方法签名冲突

现象

编译错误:Platform declaration clash: getServers()Ljava/util/List;

根因

Kotlin 的 val servers: List<T> 属性编译为 JVM 的 getServers() 方法。如果同文件中手动定义了 fun getServers() 方法,两者 JVM 签名相同,冲突。

解决方案

将内部属性重命名为 _servers(下划线前缀),保留公开方法的命名:

// 错误
private val servers = mutableListOf<McpServerConfig>()
fun getServers(): List<McpServerConfig> = servers  // JVM 签名冲突

// 正确
private val _servers = mutableListOf<McpServerConfig>()
fun getServers(): List<McpServerConfig> = _servers  // OK

相关文件

  • agent-runtime/src/main/java/org/flexui/agent/mcp/McpServerManager.kt

15. kotlinx.serialization 编译器插件不可用

现象

Kai 参考代码大量使用 @Serializable 注解和 kotlinx.serialization.json.*,直接移植到本项目的 Android 模块会编译失败。

根因

kotlinx.serialization 需要 Kotlin 编译器插件 (kotlin("plugin.serialization")),但本项目未配置该插件。添加插件需要和项目现有的 Kotlin 版本严格匹配。

解决方案

全部替换为 org.json.JSONObject / JSONArray(Android SDK 内置,零依赖):

// Kai 风格(需要 serialization 插件)
@Serializable
data class MemoryEntry(val key: String, val content: String)
val json = Json { ignoreUnknownKeys = true }
json.decodeFromString<List<MemoryEntry>>(raw)

// Android 风格(零依赖)
data class MemoryEntry(val key: String, val content: String)
val obj = JSONObject(raw)
val entry = MemoryEntry(key = obj.getString("key"), content = obj.getString("content"))

影响范围

全部 data/、mcp/、email/、sms/ 模块的序列化代码。


16. ChatViewModel 绕过 DataRepository 直接调用 AgentRuntime

现象

ChatViewModel.sendMessage() 直接调用 agentRuntime.run(),跳过 DataRepository.ask()。

影响

  • 会话不持久化(ConversationStorage 未使用)
  • Memory tools 未注册
  • System prompt 缺少技能信息
  • 丢失 Kai 的 fallback chain 机制

根因

AgentChatView 创建 ChatViewModel 时传入的是 AgentRuntime 而非 DataRepository。原始设计意图是 ChatViewModel 通过 DataRepository 间接访问 AgentRuntime。

解决方案

修改 AgentChatView 创建 DataRepository 并传入 ChatViewModel:

// AgentChatView.kt
val repo = remember { DataRepository(context) }
val viewModel = ChatViewModel(repo)

当前状态

ChatViewModel 直接使用 AgentRuntime,功能可用但缺少持久化层。后续需重构为通过 DataRepository 访问。

相关文件

  • agent-runtime/src/main/java/org/flexui/agent/ui/AgentChatView.kt
  • agent-runtime/src/main/java/org/flexui/agent/ui/ChatViewModel.kt

17. 分阶段实现中的依赖顺序

问题

Phase 2(Skills)的 BuiltinTools 依赖 Phase 2.4 的 SkillActivator,但任务被安排在 Phase 2.1。Phase 1.5 的 ToolExecutor 需要 Phase 1.6 的 LlmTypes.ToolCall。

解决方案

  1. 提前定义关键接口:在 Phase 1 先定义所有跨模块依赖的接口(ToolCall、StreamCallbacks)
  2. Stub 先行:先创建空实现(如 McpConfigLoader stub),后完成功能
  3. 重构式完成:Phase 1.5 的 ToolCall 先在 ToolExecutor.kt 内联定义,Phase 1.6 再移到 LlmTypes.kt

经验教训

写实现计划时,应该先做依赖分析,按依赖图的拓扑顺序排列任务。


18. 主线程运行 AgentRuntime.initialize() 导致 ANR 风险

现象

AgentRuntime.initialize() 包含网络请求(RemoteConfig)、文件 I/O(SkillDiscovery.scan()),在 Dispatchers.IO 上运行。

解决方案

始终在 IO 协程中初始化:

CoroutineScope(Dispatchers.IO).launch {
    agentRuntime?.initialize()
}

相关文件

  • demos/android-demo/.../MainActivity.kt

19. LazyColumn 中动态 item 类型的处理

现象

需要在消息列表中穿插渲染卡片(FlexCardView),但 LazyColumn.items() 期望单一数据类型。

解决方案

将消息和卡片展平为 List<Any>,在 items 中按类型分发:

val flatMessages = messages.flatMap { msg ->
    val items = mutableListOf<Any>(msg)
    msg.card?.let { items.add(it) }
    items
}
items(flatMessages.size) { index ->
    when (val item = flatMessages[index]) {
        is ChatMessage -> MessageBubble(message = item)
        is CardRenderData -> FlexCardView(card = item)
    }
}

相关文件

  • agent-runtime/src/main/java/org/flexui/agent/ui/ChatMessageList.kt

20. 包重命名影响范围

操作

com.openhippy.example → org.flexui.agentdemo

影响文件

文件 修改内容
build.gradle namespace + applicationId
AndroidManifest.xml package 属性
MainActivity.kt package 声明 + 目录移动
settings.gradle 无需修改(Gradle module 名 :example 不变)
scripts/android/dev.sh adb am start 包名
scripts/android/start.sh adb am start 包名

易遗漏项

  • adb am start 命令中硬编码的包名(不在 Gradle 中管理)
  • 如果 AndroidManifest.xml 的 <activity android:name=".MainActivity"> 使用的是相对路径(. 前缀),它自动匹配当前 package,无需修改

21. 使用 Logger 而非 Log 进行格式化日志

现象

Log.i(TAG, "msg=%s count=%d", str, count) 在 API < 24 的设备上编译为 Log.i(String, String) 而非 Log.i(String, String, Object...)。

解决方案

统一使用项目的 Logger 工具类(内部调用 String.format):

// 正确
Logger.info(TAG, "msg=%s count=%d", str, count)

// 错误(在 minSdk 21 上格式化失败)
Log.i(TAG, "msg=%s count=%d", str, count)

相关文件

  • agent-runtime/src/main/java/org/flexui/agent/utils/Logger.kt

22. FlexUIView 在 Compose 中的 AndroidView 集成

关键实现

FlexCardView 使用 AndroidView 将 FlexUIView(FrameLayout 子类)嵌入 Compose 树:

AndroidView(
    factory = { ctx ->
        FlexUIView(ctx).apply {
            url = card.bundleUrl
            componentName = card.componentName
            params = card.params
            client = object : FlexUIClient { ... }
        }
    },
    update = { flexView ->
        if (flexView.url != card.bundleUrl) {
            flexView.url = card.bundleUrl
            flexView.componentName = card.componentName
        }
    },
)

关键点

  • FlexUIView.loadCardInternal() 在 onAttachedToWindow() 中自动触发 —— 不需要手动调用
  • update lambda 只在参数变化时调用;首次渲染只执行 factory
  • FlexUIView 需要 FlexUIEngine.ensureInit() 已调用且引擎就绪

相关文件

  • agent-runtime/src/main/java/org/flexui/agent/ui/FlexCardView.kt
  • flexui-engine/framework/android/.../FlexUIView.kt

23. Android @HippyMethod 参数类型:HippyMap vs HippyArray

现象

callJavaScriptModule("__ascfCallbacks", "executeSkillApi", ...) 成功将调用发送到 JS 侧,JS 代码正常执行并通过 callNative('AscfAIModule', 'onSkillApiResult', {callbackId, result}) 返回结果,但 Native 侧 crash:

java.lang.reflect.InvocationTargetException
Caused by: java.lang.NullPointerException: Parameter specified as non-null is null:
    method org.flexui.agent.modelcontext.AscfAIModuleModule.onSkillApiResult, parameter params

根因

Hippy 框架中 @HippyMethod 的参数类型映射规则:

JS 传给 callNative 的 params Java 反序列化类型 @HippyMethod 参数类型
{key: value} (对象) HippyMap 必须用 HippyMap
[item1, item2] (数组) HippyArray 必须用 HippyArray
"string" (字符串) String String
42 (数字) int/double int/double

JS 侧 callNative('AscfAIModule', 'onSkillApiResult', {callbackId, result}) 传的是对象,反序列化为 HippyMap。但方法签名声明的 params: HippyArray → ArgumentUtils.parseArgument(HippyArray.class, ...) 无法转换 → 返回 null → Kotlin 非空检查抛 NullPointerException。

HippyNativeModuleInfo.invoke() 通过 Method.invoke() 反射调用,异常被包装为 InvocationTargetException。如果 exception handler 只打印 e.message(不打印 e.cause),根因完全不可见。

参考

Hippy 框架原生用例确认了此规则:

// WebSocketModule.java — JS 传对象 → HippyMap
@HippyMethod(name = "connect")
public void connect(HippyMap request, Promise promise) { ... }

// UtilsModule.java — JS 传数组 → HippyArray
@HippyMethod(name = "vibrate")
public void vibrate(HippyArray patternHippy, int repeat) { ... }

// NetworkModule.java — JS 传字符串 → String
@HippyMethod(name = "getCookie")
public void getCookie(String url, Promise promise) { ... }

解决方案

将所有 @HippyMethod 的 params 类型从 HippyArray 改为 HippyMap(因为 JS 侧统一传对象 {key: value}):

// 错误
fun onSkillApiResult(params: HippyArray, promise: Promise) = ...

// 正确 — JS 传 {callbackId, result},反序列化为 HippyMap
fun onSkillApiResult(params: HippyMap, promise: Promise) = ...

相关文件

  • android-modules/agent-runtime/src/main/java/org/flexui/agent/modelcontext/AscfAIModuleModule.kt
  • framework/android/.../HippyNativeModuleInfo.java:177 — Method.invoke() 反射调用点
  • framework/android/.../HippyModuleManagerImpl.java:383 — doCallNatives()

24. InvocationTargetException 隐藏根因 — Exception Handler 必须打印 cause chain

现象

logcat 只能看到 handleNativeException: java.lang.reflect.InvocationTargetException,没有堆栈,没有 cause。只能猜测是哪个模块崩溃。

根因

Hippy 框架通过 Method.invoke() 反射调用 @HippyMethod 方法。如果方法内部抛异常,Method.invoke() 包装为 InvocationTargetException。InvocationTargetException.getMessage() 返回 null(无 detail message),根因存在 getCause() 中。

默认的 handleNativeException 只打印 e.message:

// 错误 — 丢失根因
override fun handleNativeException(e: Exception, haveCaught: Boolean) {
    LogUtils.e(TAG, "[Engine] handleNativeException: ${e.message}")  // null!
}

解决方案

始终打印 cause chain:

override fun handleNativeException(e: Exception, haveCaught: Boolean) {
    LogUtils.e(TAG, "[Engine] handleNativeException: ${e.message}")
    var cause: Throwable? = e.cause
    while (cause != null) {
        LogUtils.e(TAG, "[Engine]   cause: ${cause.javaClass.name}: ${cause.message}")
        LogUtils.e(TAG, "[Engine]   cause stack: ${cause.stackTraceToString()}")
        cause = cause.cause
    }
    if (e.cause == null) {
        LogUtils.e(TAG, "[Engine]   stack: ${e.stackTraceToString()}")
    }
}

相关文件

  • framework/android/.../FlexUIEngine.kt — exception handler
  • framework/android/.../HippyBridgeManagerImpl.java:374 — handleMessage catch block → reportException(e)

25. Android callJavaScriptModule Native→JS→Native 双向链路调试

完整链路

Native: SkillApiBridge.executeApi()
  → callJS("executeSkillApi", [skillPath, apiName, argsJson, callbackId])
    → HippyBridgeManager.callJavaScriptModule("__ascfCallbacks", "executeSkillApi", params)
      → Handler.sendMessage(MSG_CODE_CALL_FUNCTION, FUNCTION_ACTION_CALL_JS_MODULE=6)
        → handleCallFunction(msg) — V8 序列化 + callFunction(6, ...)
          → JNI callFunction → V8 deserialize → global.hippyBridge("callJsModule", callObj)

JS: native2js.js
  → case 'callJsModule':
    → __GLOBAL__.jsModuleList[callObj.moduleName][callObj.methodName](callObj.params)
      → __ascfCallbacks.executeSkillApi(params)
        → skillApiRegistry.execute(skillPath, apiName, args)
          → API handler → result

JS→Native (回调):
  → bridge.callNative('AscfAIModule', 'onSkillApiResult', {callbackId, result})
    → V8 serialize → JNI → HippyModuleManagerImpl.doCallNatives()
      → HippyNativeModuleInfo.invoke() → Method.invoke()
        → AscfAIModuleModule.onSkillApiResult(params: HippyMap, promise)

Native: AscfAIModule.handleSkillApiResult()
  → SkillApiBridge.handleResult() → Promise resolved → API 完成

关键断点 & 日志

# 位置 确认内容 日志 TAG
1 callJavaScriptModule 参数是否正确 FlexUI: callJS -> __ascfCallbacks.$method
2 handleCallFunction 序列化成功?id=6? HippyBridgeManager: handleCallFunction: functionId=6 (callJsModule)
3 HippyBridgeImpl.callFunction mInit=true? buffer 长度? HippyBridgeImpl: callFunction: fn=callJsModule fnId=6 bufLen=...
4 V8 JNI scope 存在? logcat scope can not found (C++ FOOTSTONE_LOG)
5 native2js.js action 到达? JS console.log 或 ConsoleModule
6 __ascfCallbacks executeSkillApi 执行? [INFO:console_module.cc] '[DEBUG] ascfCallbacks executeSkillApi RECV'
7 callNative 回调 onSkillApiResult 参数类型? AscfAIModuleModule: call: method=onSkillApiResult
8 handleResult callbackId 匹配? SkillApiBridge handleResult: callbackId=...

常见误判

  • "callJavaScriptModule 不工作" → 检查方向:很可能 Native→JS 正常,是 JS→Native 的 callNative 回调失败。用 ConsoleModule.log 测试可区分。
  • "InvocationTargetException" → 不是 callJavaScriptModule 的问题,是 @HippyMethod 反射调用的方法内部抛异常。
  • "scope can not found" → JsDriver.mInstanceId 无效。检查引擎初始化是否完成(STATUS_OK)。

诊断技巧

  1. 用 ConsoleModule.log 测试 callJavaScriptModule 是否可达(如果 ConsoleModule 也没有输出,问题在 V8 序列化/反序列化层)
  2. 看 logcat 中 CallFunction: putVarint 日志 — 只要出现,说明 V8 序列化在进行
  3. 对比 handleNativeException 时间戳与 callJS dispatched 时间戳 — 如果立即出现(<50ms),说明在 Handler 分发阶段出错

相关文件

  • framework/android/.../HippyBridgeManagerImpl.java — handleCallFunction, handleMessage
  • framework/android/.../HippyBridgeImpl.java — callFunction
  • driver/js/lib/bridge/android/native2js.js — JS 侧派发
  • driver/js/packages/hippy-ascf/src/ascfCallbacks.ts — executeSkillApi
  • android-modules/agent-runtime/.../AscfAIModuleModule.kt — JS→Native 回调

26. Android debugMode=true 导致 Hippy 从 Dev Server 拉空 Bundle

现象

Hippy 引擎初始化 STATUS_ERR_BRIDGE: run script error,V8 crash。即使 jsfwk.js 已正确打包到 assets。

根因

FlexUIEngineConfig.debugMode = true 时,Hippy 不会从 assets 加载 core bundle,而是从 localhost:38989 的 dev server 拉取。如果 dev server 未运行,获取到空字符串,V8 执行空脚本 crash。

解决方案

生产/测试设备上设置 debugMode = false:

FlexUIEngine.ensureInit(FlexUIEngineConfig(
    debugMode = false,  // 从 assets 加载,不要连 dev server
    ...
))

相关文件

  • framework/android/.../FlexUIEngine.kt
  • demos/android-demo/.../MainActivity.kt

27. Webpack 缓存导致修改不生效

现象

修改了 ascfCallbacks.ts,重编 jsfwk.js 成功(webpack 无错误),但部署后 JS 行为不变。日志仍显示旧代码的错误。

根因

Webpack module cache 保留了旧的编译输出。当文件修改时间戳未触发重新编译时,webpack 使用缓存中的旧模块。

解决方案

清除 webpack 缓存后重编:

rm -rf flexui-engine/driver/js/node_modules/.cache
bash scripts/build-jsfwk.sh

相关文件

  • driver/js/scripts/build-jsfwk/index.js

28. Android build.gradle afterEvaluate 删除 Assets 目录

现象

Gradle 构建时 src/main/assets/ 目录被清空,导致 jsfwk.js 和 skill assets 丢失。

根因

旧的 build.gradle 中有:

project.afterEvaluate {
    delete 'src/main/assets'
    copy { from "res" into "src/main/assets" }
}

这会将整个 assets/ 目录删除再重建,导致构建脚本之前拷入的文件被清除。

解决方案

删除 afterEvaluate 块,改为在构建脚本中直接将文件拷贝到 src/main/assets/,Gradle 不做任何清理。

相关文件

  • demos/android-demo/build.gradle
  • scripts/build-demo.sh
  • scripts/build-jsfwk.sh

29. Android McpConfigLoader 未注入 _componentPath 导致卡片不渲染

现象

Tool 执行成功返回数据,但 chat UI 中没有卡片显示。日志中缺少 CardRenderer.resolve、Card rendered、onCardReady 等卡片渲染相关输出。

根因

卡片渲染由 AgentRuntime.run() 的 TOOL_EXECUTING 阶段触发,关键逻辑:

val componentPath = extractComponentPath(trContent)
if (componentPath.isNotEmpty()) {
    val cardData = cardRenderer.resolve(trContent)
    ...
}

extractComponentPath() 从 tool result JSON 中提取 _componentPath 字段。如果这个字段不存在,卡片渲染被跳过。

OHOS vs Android 差异:

OHOS Android(修复前)
execute() 返回 {tool, arguments, _componentPath} + merge API result 直接返回 API result(无 _componentPath)
_componentPath 来源 entry._meta.ui.componentPath → 注入到 base result → 与 API result merge 未注入,API result 没有此字段

OHOS McpConfigLoader.ets:77-85:

const result: ToolExecResult = {
    tool: this.entry.name,
    arguments: parsedArgs,
}
const compPath = entryMetaUi?.componentPath
if (compPath !== undefined && compPath !== '') {
    result._componentPath = compPath  // ← 关键注入!
}
// 执行 API → merge API result 与 base result

Android McpConfigLoader.kt(修复前)直接返回 apiResult,没有构建 base result 和注入 _componentPath。

解决方案

对齐 OHOS 逻辑,在 McpConfigToolExecutor.execute() 中:

  1. 构建 base result JSON {tool, arguments, _componentPath}(含 entry metadata 中的 componentPath)
  2. 执行 Skill API 获取业务结果
  3. Merge API result 到 base result(API 字段覆盖 base 字段)
  4. 返回合并后的 JSON
override suspend fun execute(args: Map<String, Any>): Any {
    val baseResult = JSONObject().apply {
        put("tool", entryName)
        put("arguments", JSONObject(args.toMap()))
        if (!componentPath.isNullOrEmpty()) {
            put("_componentPath", componentPath)  // ← 注入!
        }
    }
    val apiResult = apiBridge.executeApi(skillPath, entryName, args)
    // Merge: API result keys overwrite base result keys (matching OHOS)
    val apiObj = JSONObject(apiResult)
    val apiKeys = apiObj.keys()
    while (apiKeys.hasNext()) {
        val key = apiKeys.next()
        baseResult.put(key, apiObj.get(key))
    }
    return baseResult.toString()
}

注意:Merge 时 API result 覆盖 base result。例如 API result 中有 arguments 字段会覆盖 base result 中的 arguments,这是预期行为。

验证日志

[McpConfigLoader] Skill API result: ... len=1027
[ToolExecutor] execute: tool=searchDrinks resultLen=1159  ← len 增加了 132 bytes(base metadata)
[CardRenderer] Resolved card: components/recommended-drinks/index -> /skills/.../index.pack.js/drink_recommended-drinks
[AgentRuntime] Card rendered: components/recommended-drinks/index -> instanceId=drink_recommended-drinks_1
[ChatViewModel] onCardReady: component=drink_recommended-drinks ...

数据流

mcp.json entry._meta.ui.componentPath
  → McpConfigToolExecutor.componentPath
    → execute() → baseResult._componentPath
      → merge with API result → finalResult._componentPath
        → AgentRuntime.extractComponentPath() → componentPath
          → CardRenderer.resolve() → CardRenderData
            → InstanceManager.allocate() → dispatchResult()
              → ChatViewModel.onCardReady() → FlexCardView 渲染

相关文件

  • android-modules/agent-runtime/.../McpConfigLoader.kt:405-455 — McpConfigToolExecutor.execute()
  • android-modules/agent-runtime/.../AgentRuntime.kt:263-316 — TOOL_EXECUTING 卡片渲染
  • ascf-agent/agent-runtime/.../McpConfigLoader.ets:70-99 — OHOS 参考实现