我一直觉得UI 设计正在经历一次类似从命令行到图形界面的切换过去我们设计界面是为了让用户按预设路径操作输入框、按钮、列表都是提前画好的现在做 ArkUI 里的 AI 原生 UI核心是让界面能听懂意图、能自动生成、能随结果变化。换句话说传统 UI 是图纸先行AI 原生 UI 是运行时生长。这篇文章我想用 ArkUI 的声明式语法、状态管理和组件体系聊聊怎么把 AI 能力真正嵌进界面里而不是简单地在页面上挂一个AI 按钮。1. 别急着接大模型先想清楚 AI 原生 UI 和传统 UI 的差别很多人一听到AI 原生 UI第一反应是找一个大模型 API 接进来然后在页面上加个聊天入口。但我做了几个项目之后发现这个理解容易把方向带偏。AI 原生 UI 不是UI AI 功能而是界面本身的形态和驱动方式都要跟着 AI 的能力改变。要设计好它得先承认传统 UI 的默认假设已经不够用了。1.1 传统 UI 的三个隐含假设正在失效传统 UI 设计默认了三件事第一界面结构是确定的今天画好的页面明天还是长这样第二用户输入是离散的点击、输入、提交每次操作都有明确边界第三数据是系统给的表单字段、列表项、图表维度都是后端接口定义好的。这三个假设在大部分管理类、工具类应用里没问题但只要涉及 AI就全变了。AI 的输出不确定。同一个问题模型今天可能给你一段文字明天可能给你一个表格后天可能直接给你一段操作建议。页面结构如果在编译期定死AI 的结果就塞不进去。AI 的输入也更像一段意图不是一个规范的字段值。用户说帮我安排下周三下午的会议顺便订个会议室你很难把它拆成 form 表单里的三个空。AI 的响应还会分阶段思考中、流式输出、结果修正每个阶段 UI 都得有对应表现。1.2 AI 原生 UI 的三个新关键词意图、生成、反馈我在设计 ArkUI 页面时会刻意用三个词替代传统 UI 的概念意图、生成、反馈。意图是用户侧的表达可能是语音、文本、多模态输入UI 要能把意图转成结构化的任务生成是 AI 侧的行为UI 要能根据生成结果动态调整自己的结构反馈是两者之间的桥梁从正在理解到正在生成再到结果可用每一步都要可感知。这三个词直接决定了 ArkUI 代码怎么写。比如传统页面里一个输入框绑定一个 State userName提交时读取 userName 就行AI 原生页面里输入框的内容可能先进入一个意图理解层然后生成一个 action 对象页面再根据 action 对象动态决定渲染表单、卡片还是列表。这不是加一个 API 调用那么简单而是数据流整个变了。维度传统 UIAI 原生 UI页面结构编译期确定运行期根据 AI 结果生长输入方式表单/点击自然语言/语音/多模态状态数量有限且可控流式、候选、纠错、回退失败模式接口报错模型输出格式错误、意图不明、超时主要开发成本布局和交互状态编排和结果渲染如果你已经用 ArkUI 写过几个页面应该能感觉到ArkUI 的声明式语法天然适合做动态结构因为状态一变UI 就自动跟着变。问题只在于你怎么设计这套状态让 AI 的输出能顺畅地变成界面的变化。2. 在 ArkUI 里给 AI 输出腾位置状态设计与数据流写 AI 原生 UI第一件事不是选组件而是先把数据流理清楚。我把 AI 相关的界面状态分成四层输入层、任务层、结果层、展示层。每一层对应 ArkUI 里不同的状态管理方式。2.1 输入层把用户意图变成可理解的任务输入层负责接收用户的自然语言、语音或图片。在 ArkUI 里我会用一个全局的 intentState 对象管理当前输入而不是让每个输入框自己存数据。这样做的原因是AI 场景里用户可能连续表达多个意图比如帮我查一下本周的天气再根据天气推荐适合跑步的时间你需要在 UI 层先聚合输入再统一交给 AI 服务解析。代码上我通常把输入事件统一收敛到一个方法里// 输入层统一收集意图 class IntentState { rawText: string ; attachments: Arraystring []; source: text | voice | image text; task?: AITask; // 解析后的结构化任务 } State intent: IntentState new IntentState(); async function parseIntent() { // 调用 AI 意图理解接口返回结构化任务 const task await aiService.parseIntent(this.intent.rawText); if (task ! undefined) { this.intent.task task; // 触发任务层和展示层刷新 } }这里的关键点在于不要让 UI 直接消费模型返回的原始文本。原始文本是给人看的UI 需要的是字段、动作、对象、时间这些结构化信息。所以输入层之后一定要有一个解析动作把自然语言翻译成内部的数据结构UI 再根据这个结构决定怎么画。2.2 任务层把 AI 任务的进度变成状态机AI 任务不是瞬间完成的。从请求发出到结果返回中间有网络耗时、模型推理耗时、甚至多轮工具调用耗时。如果 UI 只用一个 loading 状态用户根本不知道发生了什么。我习惯把任务进度设计成一个状态机idle、parsing、thinking、rendering、done、error。在 ArkUI 里这个状态机可以直接用一个联合类型加上 State 管理type AITaskStatus | idle | parsing | thinking | rendering | done | error; State taskStatus: AITaskStatus idle; State taskMessage: string ; function updateTaskStatus(status: AITaskStatus, message: string) { this.taskStatus status; this.taskMessage message; }这样 UI 层就可以针对不同状态渲染不同内容。重点是不要只在状态切换时弹一个 Toast而是让整个页面结构和这个状态机联动。比如 thinking 状态展示一个缩略的推理摘要卡片rendering 状态展示骨架屏done 状态展示完整内容。ArkUI 的条件渲染和状态绑定让这件事非常简单。2.3 结果层让模型输出落到可观察的数据结构结果层是 AI 原生 UI 的核心。模型返回的内容无论是 JSON 还是 Markdown都要被转换成 ArkUI 可观察的数据结构。我有一个比较稳定的做法先让模型按固定的 schema 返回然后在前端做一次归一化把结果统一成三种类型消息流、实体集合、动作指令。消息流适合对话、解释、文案生成对应 List Text 渲染。实体集合适合列表、卡片、表格对应 ForEach 动态组件。动作指令适合表单填充、页面跳转、设置修改对应一个指令执行器。归一化之后UI 层只跟这三种结构打交道不用关心模型后面是换了提示词还是换了模型版本。整个系统像有个翻译层模型输出是源语言ArkUI 状态是目标语言。2.4 展示层用 ArkUI 的响应式能力驱动视图最后才是组件怎么画。ArkUI 的声明式语法最爽的地方在于你只需要描述状态是什么样UI 就是什么样。我用 State 或 Observed 对象持有结果层的结构然后在 build 方法里用条件渲染和循环渲染表达所有可能形态。Builder function ResultRenderer(blocks: ArrayUIBlock) { ForEach(blocks, (block: UIBlock) { if (block.type paragraph) { Text(block.content) } else if (block.type table) { // 渲染表格 } else if (block.type actions) { // 渲染操作按钮 } }) }这一层不太需要复杂逻辑它的任务只有一个状态变了界面跟着变。数据流设计好了UI 层就是纯渲染调试起来非常轻松。3. 适合 AI 场景的 ArkUI 组件选型与组合方式ArkUI 的组件库很多但不是所有组件都适合做 AI 原生界面。我踩过一些坑之后总结了一套自己的选型逻辑先看这个组件的更新模式适不适合 AI 输出的频率和形态。3.1 文本流与对话场景List、Text 与 ScrollAI 对话、流式输出、Markdown 渲染这几个场景最常用的是 List、Text 和 Scroll。流式输出意味着文本会频繁追加如果整个页面用一个 Text 渲染长文本每次状态更新都可能引发整块区域的重绘。我更建议把流式输出拆成消息数组每条消息一个 Text用 List 的懒加载机制管理。State messages: ArrayChatMessage []; // 每条消息是一个独立状态 class ChatMessage { role: user | assistant user; content: string ; status: pending | streaming | done pending; } build() { List({ space: 12 }) { ForEach(this.messages, (msg: ChatMessage) { ListItem() { ChatBubble({ message: msg }) } }) } }流式更新的时候只更新最后一条 message.content前面的消息不会重绘性能会好很多。实际上 ArkTS 对数组 item 的更新判断也不错但拆分消息粒度依然是更稳的做法。3.2 动态内容区ForEach、if/else 与 BuilderAI 生成的结果往往不是单一类型可能一段说明后面跟着一张卡片接着一个表格再往后是几个操作按钮。这种场景我不用 Scroll 里堆一堆组件而是定义一个UIBlock 数组用 Builder 实现一个块级渲染器。每一块负责一种 UI 形态AI 返回什么结构就渲染什么块。Builder function AIBlock(block: AIBlock) { if (block.type headline) { Text(block.text).fontSize(20).fontWeight(FontWeight.Bold) } else if (block.type paragraph) { Text(block.text).fontSize(16) } else if (block.type table) { TableView(block.data) } else if (block.type cards) { Row() { ForEach(block.data, (item: CardData) { CardView(item) }) } } }这种方式的缺点是需要维护 UIBlock 的结构定义有点烦。但它带来的好处非常大产品经理加一种新内容形态前端只需要加一个 block.type 分支AI 那边也只需要在输出时多给一个 block 对象两边都解耦。3.3 画布与自定义渲染Canvas 的用武之地有些 AI 输出不适合用现成组件表达比如思维导图、知识图谱、图表、手绘风格的草图。这套内容用 List 堆不出来我会直接用 Canvas。ArkUI 的 Canvas 组件提供 CanvasRenderingContext2D可以画线、画圆、绘制文本而且可以在状态变化后重新绘制。AI 生成图谱这类场景渲染层其实是最不重要的真正的难点是把模型返回的节点和边关系转成坐标。我的做法是先算布局再把布局结果存成 State最后 Canvas 根据布局数据绘制。不要在 draw 回调里直接调 AI 接口也不要在 draw 里做复杂计算否则界面会卡成幻灯片。3.4 表单与指令执行动态表单替代写死的表单项AI 另一个很实用的能力是帮用户填表。比如用户说帮我申请一张出差审批单明天去上海预算 3000AI 解析之后可以直接把表单字段填好。传统做法是页面里写死一堆 TextInputAI 填完后逐个赋值。更好一点的做法是让表单本身也是数据驱动的字段列表来自 AI 解析结果。我用过一个动态表单渲染器核心是一个 fields 数组class FormField { key: string ; label: string ; type: text | number | date | select text; value?: string; options?: Arraystring; }build 里用 ForEach 遍历 fields动态创建 TextInput、DatePicker 或 Select。AI 只要返回字段结构表单自动生成。用户改了一个值State 里的 fields 同步更新提交时直接把整个 fields 结构发给后端。这套思路最大的好处是支持 AI 预填、支持用户修改、支持后端动态扩展字段。4. 从自然语言到界面一个 AI 生成工作台的最小可跑通案例光讲设计原则有点虚我拿一个真实做过的场景拆一下AI 生成工作台。用户输入一段自然语言比如给部门下周的 OKR 做一张进度看板按负责人分组系统自动识别意图生成一个看板界面。整个过程可以拆成五步。4.1 第一步意图解析与任务规划用户点击发送按钮后页面先进入 thinking 状态显示正在理解需求。这里我把用户的原始文本发给 AI 服务要求返回一个结构化的任务描述包含目标组件类型、数据维度和操作指令。这一步看似简单但提示词很关键。我用了类似只输出 JSON不要解释的约束并且给出几个示例否则模型很容易返回一段散文。4.2 第二步把目标转成 UI SchemaAI 返回一个 JSON 后我不会直接渲染 JSON而是先把它转换成一个 UI Schema。比如{ type: dashboard, title: 部门下周 OKR 进度看板, groupBy: owner, metrics: [progress, risk], layout: grid }然后前端有一个 schema2UI 的映射器把这个 JSON 转换成 ArkUI 可以渲染的页面结构。这一步的好处是UI 不直接跟模型返回格式耦合模型以后改了输出结构前端只需调整映射器不需要动组件代码。4.3 第三步状态管理承接动态结构我在页面里定义了这样的状态State schema: DashboardSchema | null null; State taskStatus: AITaskStatus idle; State errorMessage: string ;schema 一旦被赋值UI 就会自动从空状态切换到看板状态。看板里的分组、指标卡、进度条全部由 schema 生成。用户不需要刷新也不需要等待一次完整接口返回schema 到达的瞬间页面就变了这个体验非常接近AI 在帮我做界面的感觉。4.4 第四步渲染与交互闭环看板生成之后用户还可以继续自然语言修改比如把这个看板改成按项目分组此时不是重建页面而是复用同一个 schema 状态只更新 groupBy 字段。这也体现了 AI 原生 UI 和传统 UI 的一大差异用户对界面的操作不再只是点击按钮而是可以通过语言直接修改界面的组织结构。ArkUI 的声明式状态管理让这种界面可编程成为可能。4.5 第五步结果持久化与二次加载生成好的 schema 不能只存在内存里。我会把它序列化后存到本地数据库或服务端下次用户打开工作台直接把 schema 拉回来渲染。这一步看起来简单但很体现工程成熟度AI 结果一旦持久化它就从一次性生成变成了可复用资产界面的加载成本也大幅降低。5. 流式输出、加载反馈与失败降级把 AI 的不确定性变成体验AI 接口最让人头疼的不是慢而是不确定。你会等很久你会拿到格式不对的返回你会遇到模型把字段名改了的情况。如果 UI 不做处理用户感知到的就是卡了或者报错了。这一章聊聊我的处理经验。5.1 流式输出逐段更新而不是等完整结果流式输出是 AI 原生 UI 体验的分水岭。同样是等 10 秒让用户盯着转圈和让用户看着文字逐行出现感知完全不同。ArkUI 里做流式输出我通常通过 WebSocket 或分块响应接口接收文本片段然后更新消息数组的最后一条。有一个性能细节需要注意如果每收到一个 token 就直接 setStateUI 刷新频率可能超过屏幕刷新率反而导致卡顿。我建议做节流比如每 80 毫秒强制刷新一次或者每累积一定长度再更新。实测下来这样既保留了逐字输出的观感也不会让主线程忙不过来。5.2 骨架屏与局部刷新AI 在生成时页面不应该完全空白。我习惯在任务状态进入 thinking 或 rendering 时先渲染一个骨架屏或者根据已返回的部分 schema 渲染局部内容。比如上面那个看板案例AI 还没返回完整 metrics 时我先把 title 和分组维度渲染出来后面指标卡再逐个填充。ArkUI 里只要把数据拆细一点用 if/else 控制局部可见性这种渐进式呈现很容易实现。5.3 失败降级AI 挂了UI 不能跟着挂AI 服务一定会出错这不是如果的问题而是什么时候的问题。我设计的任何 AI 原生页面都必须有三个降级层次。第一层是重试针对超时和瞬时网络错误。第二层是简化模式AI 解析不出来的时候把 UI 退回成普通文本输入框让用户手动填表或手动操作。第三层是缓存回退如果同样的意图之前成功过就先用上一次的结果顶着同时提示用户内容可能不是最新的。这三层降级在 ArkUI 里其实就是几个 if 分支检测到 error 状态展示重试按钮和一个手动输入入口。检测到本地有缓存 schema优先渲染缓存。检测到弱网自动切换轻量 UI去掉封面图和复杂动画。很多 AI 产品死在模型一崩全站瘫痪这跟模型能力关系不大反而是 UI 设计的事。5.4 交互降级不是所有用户都喜欢 AI还有一个容易被忽略的问题不是所有用户都想让 AI 接管界面。老用户可能更习惯自己点按钮、自己填表。所以我会在 AI 生成的结果旁边保留手动编辑入口。ArkUI 的交互成本很低加一个切换编辑模式的图标就行。这个设计看着不起眼但真正上线后会发现它是留存率的分水岭。6. 我踩过的坑AI 结果渲染中的常见问题与收尾经验最后这部分我不想写成教科书就聊聊实际开发里反复踩的几个坑以及我现在的处理方式。6.1 模型返回 JSON 格式漂移最坑的一个问题模型明明在提示词里答应了只返回 JSON实际返回时偏要在 JSON 前后加 Markdown 代码块标记或者把 key 从 camelCase 改成 snake_case。我现在的做法是前端写一个宽松解析器先剥离代码块标记再做字段归一化映射解析失败就整个走降级流程。不要指望提示词能彻底约束模型前端一定要兜底。6.2 状态更新太频繁导致丢帧AI 流式输出时如果把每一次 token 都塞进 StateUI 线程会被频繁唤醒掉帧几乎是必然的。我后来改成缓冲区 定时器模式token 先进入一个普通数组定时器每 100 毫秒把累计的文本同步到 State。体验不差性能稳定很多。6.3 组件销毁时异步回调还在更新ArkUI 页面退出时如果 AI 请求的回调还在执行并且你接着去更新 State轻则警告重则崩溃。处理方式是在任务开始时记录一个 CancellationToken页面 onPageHide 或组件 aboutToDisappear 时置为取消状态回调里先检查再更新。这个细节特别重要尤其是用户在 AI 生成过程中频繁切换页面的场景。6.4 长文本渲染的耗损AI 每次生成几千字的报告直接把字符串塞进 Text滚动时会明显掉帧。我现在的做法是分页或分段渲染只渲染可视区域附近的内容。ArkUI 的 List 配合 LazyForEach 很适合这种场景把长文本切成 paragraphs 数组按需加载。虽然切分逻辑写起来有点啰嗦但用户滚动体验平滑多了。6.5 设计文档和 AI 的输出 schema 必须一起维护这个不是技术问题是协作问题。AI 原生 UI 里后端接口、前端组件、模型输出 schema 三者强耦合。如果模型改了输出结构前端没同步更新页面就会出现诡异的空白。我现在会专门维护一份UI Schema 文档模型侧和前端侧用同一份定义每次改动都过评审。这个习惯帮我省掉了大量线上排查时间。做了几个 AI 原生项目之后我最大的体会是AI 原生 UI 真正考验的不是你会不会调模型接口而是你能不能把模型输出变成稳定的、可观察、可降级的界面状态。ArkUI 的声明式状态管理非常适合这个目标因为它让界面随状态生长变成默认能力。如果你正准备在 HarmonyOS 应用里接入 AI我建议别先从聊天框做起挑一个高频场景把从意图输入到 schema 渲染再到失败降级的整条链路跑通你会比那些只加了一个 AI 按钮的应用领先一大截。