在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

HarmonyOS API 24 实战:ArkTS 构建 AI 剧本创作 App 全流程解析

项目: AI 剧本 (AI Script Writer)
平台: HarmonyOS · ArkTS · API 24
构建: Hvigor 6.1.0 · Stage 模型
包名: com.atomgit.aiplay
源码: 基于 demo012 项目中的 AI 剧本模块
字数: ≈ 10,500 字


目录

  1. 引言
  2. 项目概述与技术栈
  3. ArkTS 语言与 HarmonyOS 开发环境
  4. 项目架构与工程结构
  5. 类型系统与数据模型设计
  6. AI 剧本生成引擎
  7. 数据持久化层:AppStorage 实战
  8. UI 层:ArkUI 声明式界面构建
  9. 组件化设计与回调传递
  10. 状态管理深入:@State / @Prop / @Link
  11. 用户交互与体验优化
  12. 测试策略与质量保障
  13. 构建与预览配置
  14. 踩坑记录与 ArkTS 合规要点
  15. 总结与展望

1. 引言

2026 年的移动端开发格局中,HarmonyOS 已经从一个新兴平台成长为不可忽视的生态力量。其原生的 ArkTS 语言和 ArkUI 框架,为开发者提供了与 SwiftUI、Jetpack Compose 相似的声明式 UI 开发体验,同时有着独特的语法约束和性能特性。

本文分享的 AI 剧本 App 是运行在 HarmonyOS API 24 上的一款创意工具应用。它利用模板引擎技术,让用户可以在手机上快速生成完整的剧本——包括角色表、场景描述、对话台词和舞台说明。用户只需选择题材和风格,点击"AI 生成"按钮,即可获得一份结构完整的剧本。

本文将从实际开发角度,系统阐述以下内容:

  • 如何在 ArkTS 严格模式下构建类型安全的应用
  • 如何设计模板驱动的 AI 内容生成引擎
  • 如何利用 AppStorage 实现轻量级数据持久化
  • ArkUI 声明式 UI 的最佳实践
  • 组件化设计与状态管理技巧

无论你是初次接触 HarmonyOS 开发,还是从其他平台迁移过来的经验开发者,都能从本文中获得实用的编码思路。


2. 项目概述与技术栈

2.1 应用功能矩阵

AI 剧本 App 围绕"AI 辅助创作"这一核心场景,提供了一整套创作工具链:

功能模块 子功能 用户交互
AI 生成 题材选择(10 种)、风格选择(5 种)、参数配置 水平滚动选择器、折叠面板、表单输入
剧本管理 剧本库、搜索、收藏、删除 列表 + 搜索框 + 标签筛选
角色管理 角色 CRUD、角色卡展示 表单编辑 + 卡片展示
剧本详情 剧本阅读、场景导航 场景页码导航 + 滚动阅读
AI 续写 追加场景 一键续写 3 场
导出分享 复制到剪贴板 pasteboard API
统计面板 剧本总数、总字数、收藏数 首页 Hero 区域展示

2.2 技术栈规格

层级 技术选型 版本/备注
编程语言 ArkTS TypeScript 超集,带严格模式约束
UI 框架 ArkUI 声明式组件体系
状态管理 @State / @Prop / @Link 组件内响应式
数据持久化 AppStorage 全局 KV 存储,JSON 序列化
构建系统 Hvigor 6.1.0,基于 Gradle 的定制构建
目标 API API 24 (HarmonyOS) Stage 模型
应用模型 Stage 单 Ability + 备份扩展

2.3 为何选择模板引擎而非 LLM API?

当前阶段,许多 AI 写作应用都选择接入大语言模型(LLM)API。本项目的"AI"引擎却采用了模板 + 随机组合的技术路线,原因如下:

  1. 零成本运行:无需 API Key,无需网络请求,完全离线
  2. 瞬时响应:生成过程在 50ms 内完成(UI 模拟 1200ms 延迟以营造"AI思考"感)
  3. 内容可控:台词模板经过人工筛选,不存在敏感内容或逻辑断裂
  4. 开箱即用:用户下载 App 即可使用,无需注册或付费

当然,模板引擎的局限性也很明显——内容重复度较高,缺乏真正的创造性。在未来的版本中,我们计划增加"LLM 模式"作为高级选项。


3. ArkTS 语言与 HarmonyOS 开发环境

3.1 开发环境搭建

API 24 对应 HarmonyOS SDK 的中期版本。推荐使用 DevEco Studio(基于 IntelliJ IDEA 定制)作为 IDE。

关键配置文件

// hvigor/hvigor-config.json5
{
  modelVersion: "6.1.0",
  // execution: { ... }
}

// entry/build-profile.json5
{
  apiType: "stageMode",
  buildOption: {
    arkOptions: {
      strictMode: "strict"  // ArkTS 严格模式
    }
  }
}

3.2 ArkTS 与 TypeScript 的关键差异

ArkTS 是 TypeScript 的子集,移除了部分动态特性以确保编译期类型安全和运行时性能。以下是必须遵守的规则:

规则 1:禁止解构赋值
// ❌ 编译错误
const { name, age } = person;

// ✅ 正确写法
const name = person.name;
const age = person.age;

这条规则初看繁琐,但它避免了深层嵌套解构带来的隐式引用问题,让数据流向更清晰。

规则 2:禁止展开运算符
// ❌ 编译错误
const newObj = { ...oldObj, extra: true };

// ✅ 正确写法——逐个字段复制
const newObj: MyType = {
  field1: oldObj.field1,
  field2: oldObj.field2,
  extra: true,
};

在本项目的数据存储层中,这条规则的影响尤为明显。每次更新剧本都需要显式复制每个字段:

const updated: Script = {
  id: s.id, title: s.title, genre: s.genre, style: s.style,
  logline: s.logline, prompt: s.prompt, content: s.content,
  characters: s.characters, scenes: s.scenes,
  createdAt: s.createdAt, updatedAt: s.updatedAt,
  wordCount: s.wordCount, isFavorite: !s.isFavorite, actCount: s.actCount,
};

虽然代码量增加了,但每个字段的赋值都一目了然,减少了因展开运算符隐式合并导致的 bug。

规则 3:禁止 any / unknown
// ❌ 编译错误
let data: any;

// ✅ 必须显式标注类型
let data: string = '';
let count: number = 0;
let result: Script | null = null;

这条规则有效地杜绝了运行时类型错误。在 JSON 解析场景中,我们需要通过类型断言来处理:

export function loadScripts(): Script[] {
  try {
    const data = AppStorage.get<string>(STORAGE_KEY_SCRIPTS);
    if (data) {
      const parsed: object = JSON.parse(data);
      if (Array.isArray(parsed)) {
        const result: Script[] = [];
        for (let i = 0; i < parsed.length; i++) {
          result.push(parsed[i] as Script);
        }
        return result;
      }
    }
  } catch (_) {}
  return [];
}

注意这里的 as Script 类型断言——ArkTS 允许对已明确类型的变量进行断言,但不允许在不确定的场景下使用。

规则 4:@Builder 内禁止变量声明
// ❌ 编译错误
@Builder
MyView() {
  const temp = someValue;
}

// ✅ 逻辑移到 struct 方法中
getFormattedValue(): string {
  return someValue.toUpperCase();
}

@Builder
MyView() {
  Text(this.getFormattedValue())
}

3.3 严格模式下的额外约束

build-profile.json5 中设置 strictMode: "strict" 后,编译器会增加更多检查:

  • 索引访问类型禁止Script['characters'] 这种写法不被允许
  • 未使用导入报错:导入但未使用的符号会引发警告或错误
  • 空值检查增强:使用 ! 非空断言需确保在前置条件中已判空

4. 项目架构与工程结构

4.1 分层架构设计

┌───────────────────────────────────────────┐
│          UI Layer (ArkUI)                  │
│  pages/111.ets        主页面 + 6 子页面     │
│  components/          7 个可复用组件         │
├───────────────────────────────────────────┤
│       State Management Layer               │
│  @State / @Prop / @Link                   │
├───────────────────────────────────────────┤
│       Business Logic Layer                 │
│  ScriptGenerator   AI 生成引擎              │
│  CollectionManager 盲盒管理器 (其他模块)     │
├───────────────────────────────────────────┤
│       Data Access Layer                    │
│  ScriptStorage     AppStorage CRUD         │
├───────────────────────────────────────────┤
│       HarmonyOS System APIs                │
│  pasteboard / preferences / AppStorage    │
└───────────────────────────────────────────┘

4.2 工程文件结构

entry/src/main/ets/
├── pages/
│   └── 111.ets               # 主入口页面(913 行)
├── components/
│   ├── BlindBoxItem.ets      # 盲盒组件
│   ├── FrameThumb.ets        # 帧缩略图
│   ├── PlaybackControl.ets   # 播放控制
│   ├── ScanResultPanel.ets   # AR 识别面板
│   ├── ScriptCard.ets        # 剧本卡片(新增)
│   ├── ScriptResultCard.ets  # 生成结果卡(新增)
│   ├── StickerCard.ets       # 3D 贴纸卡片
│   ├── TimelineBar.ets       # 时间轴裁剪
│   └── VideoPreview.ets      # 视频预览
├── model/
│   ├── ScriptTypes.ets       # 类型定义 + 常量
│   ├── ScriptGenerator.ets   # AI 生成引擎(595 行)
│   ├── ScriptStorage.ets     # 数据持久化(188 行)
│   ├── CollectionManager.ets # 收藏管理器
│   └── StickerData.ets       # 贴纸数据
├── entryability/
│   └── EntryAbility.ets      # Ability 生命周期
└── entrybackupability/
    └── EntryBackupAbility.ets # 备份扩展

4.3 单组件多页面路由模式

本项目采用了一种略有争议的架构选择——单组件 + 状态驱动的页面路由,而非使用 HarmonyOS 的 router API。

@Entry
@Component
struct AppMain {
  @State private page: string = 'home';

  build() {
    Stack() {
      Column() {
        if (this.page === 'home') { this.HomePage(); }
        else if (this.page === 'generate') { this.GeneratePage(); }
        else if (this.page === 'list') { this.ListPage(); }
        else if (this.page === 'detail') { this.DetailPage(); }
        else if (this.page === 'characters') { this.CharactersPage(); }
        else if (this.page === 'char_edit') { this.CharacterEditPage(); }
      }
      // Toast 浮层
    }
  }
}

这种模式的优点

  • 所有状态集中在 AppMain 中,子页面之间共享数据无需跨路由传参
  • 页面切换无动画开销(直接通过条件渲染切换)
  • 适合小型应用快速迭代

缺点

  • 单文件较大(本项目 913 行)
  • 不适用于超过 10 个页面的大型应用
  • @Builder 内无法使用生命周期钩子

5. 类型系统与数据模型设计

5.1 核心类型体系

良好的类型系统是 ArkTS 项目的基石。以下是 AI 剧本的核心类型设计:

// 剧本实体——整个应用的核心数据模型
export interface Script {
  id: string;                // 唯一标识
  title: string;             // 标题(由 logline 自动截取)
  genre: string;             // 题材标识
  style: string;             // 风格标识
  logline: string;           // 一句话故事梗概
  prompt: string;            // 用户自定义提示词
  content: string;           // 剧本正文(纯文本格式)
  characters: ScriptCharacter[];  // 角色列表
  scenes: SceneInfo[];       // 场景列表
  createdAt: number;         // 创建时间戳
  updatedAt: number;         // 更新时间戳
  wordCount: number;         // 总字符数
  isFavorite: boolean;       // 是否收藏
  actCount: number;          // 幕数
}

// 角色
export interface ScriptCharacter {
  id: string;                // 角色唯一 ID
  name: string;              // 角色名
  age: string;               // 年龄(字符串,如"25岁")
  gender: string;            // 性别
  personality: string;       // 性格描述
  appearance: string;        // 外貌描述(当前保留字段)
  background: string;        // 背景故事
  goals: string;             // 角色目标
  role: string;              // 角色定位:主角/反派/配角/龙套
}

// 场景
export interface SceneInfo {
  id: string;
  title: string;             // 场景标题(如"内景:咖啡馆 - 黄昏")
  location: string;          // 地点
  timeOfDay: string;         // 时段
  isInterior: boolean;       // true=内景,false=外景
  actNumber: number;         // 所属幕
  sceneNumber: number;       // 场景序号
  summary: string;           // 场景摘要
}

5.2 ID 生成策略

在不引入外部 UUID 库的前提下,使用时间戳 + 随机数组合生成唯一 ID:

export function genId(): string {
  return 's_' + new Date().getTime() + '_' + Math.floor(Math.random() * 99999);
}

export function charId(): string {
  return 'c_' + new Date().getTime() + '_' + Math.floor(Math.random() * 99999);
}

export function sceneId(): string {
  return 'sc_' + new Date().getTime() + '_' + Math.floor(Math.random() * 99999);
}

设计考量

  • getTime() 返回毫秒级时间戳,同一毫秒内可能重复,故加随机数后缀
  • 使用前缀(s_ / c_ / sc_)便于调试时快速区分 ID 类型
  • 随机数范围 0-99999,碰撞概率极低

5.3 常量数据组织

题材、风格、角色类型等常量数据集中存在于 ScriptTypes.ets 中,并配有对应的查询函数:

export const GENRE_LIST: GenreItem[] = [
  { id: 'comedy',  name: '喜剧',   icon: '😄', desc: '轻松幽默、笑中带泪' },
  { id: 'tragedy', name: '悲剧',   icon: '😢', desc: '命运无常、深刻感人' },
  { id: 'mystery', name: '悬疑',   icon: '🔍', desc: '层层推理、反转不断' },
  { id: 'sci-fi',  name: '科幻',   icon: '🚀', desc: '未来世界、科技奇想' },
  { id: 'romance', name: '爱情',   icon: '💕', desc: '情感纠葛、甜蜜虐心' },
  { id: 'history', name: '历史',   icon: '🏛️', desc: '王朝更迭、英雄史诗' },
  { id: 'fantasy', name: '奇幻',   icon: '🪄', desc: '魔法世界、冒险传奇' },
  { id: 'horror',  name: '恐怖',   icon: '👻', desc: '心理恐惧、惊悚氛围' },
  { id: 'action',  name: '动作',   icon: '💥', desc: '紧张刺激、热血燃爆' },
  { id: 'literary',name: '文艺',   icon: '📖', desc: '细腻叙事、艺术表达' },
];

export function genreName(id: string): string {
  for (let i = 0; i < GENRE_LIST.length; i++) {
    if (GENRE_LIST[i].id === id) return GENRE_LIST[i].name;
  }
  return id;
}

export function genreIcon(id: string): string {
  // 同样的线性查找模式
}

为什么不使用 Record/MAP? 因为 ArkTS 不支持 Map 类型,Record<string, string> 也是一种替代方案,但对于不超过 10 项的列表,线性查找的开销可以忽略不计,且代码更直观。

5.4 时间格式化工具

用户友好的相对时间展示:

export function fmtTime(ts: number): string {
  const d = new Date(ts);
  const diff = Date.now() - ts;
  const m = Math.floor(diff / 60000);
  if (m < 60) { return m + '分钟前'; }
  const h = Math.floor(diff / 3600000);
  if (h < 24) { return h + '小时前'; }
  const day = Math.floor(h / 24);
  if (day < 30) { return day + '天前'; }
  return (d.getMonth() + 1) + '月' + d.getDate() + '日';
}

这段代码实现了"5 分钟前 → 3 小时前 → 2 天前 → 6 月 20 日"的渐进式时间展示,避免了"刚刚"、"昨天"等需要额外字典映射的表述。


6. AI 剧本生成引擎

6.1 引擎架构设计

ScriptGenerator.ets 是整个项目最核心的模块,包含约 600 行代码。其设计理念是 模板驱动 + 随机组合,工作原理如下图所示:

generateScript(params)
    │
    ├── 1. 标题生成
    │   从 logline 截取前 20 字,不足则使用"未命名剧本"
    │
    ├── 2. 角色生成(× characterCount)
    │   随机性别 → 随机姓名 → 随机年龄 → 随机性格 → 设定目标
    │
    ├── 3. 场景列表生成(× sceneCount)
    │   按幕分组 → 随机地点 → 随机时段 → 内景/外景
    │
    └── 4. 正文生成
         ├── 角色表章节
         ├── 逐场景生成内容
         │    ├── 动作描述(从题材对应的动作库选取)
         │    ├── 对话(从题材对应的对话库选取)
         │    └── 舞台说明("灯暗"标记)
         └── 全剧终 + 版权信息

6.2 角色生成算法

function generateCharacter(index: number, total: number, genre: string): ScriptCharacter {
  const isMale = Math.random() > 0.5;
  const names = isMale ? CHINESE_NAMES_MALE : CHINESE_NAMES_FEMALE;
  const name = names[Math.floor(Math.random() * names.length)];
  const trait = PERSONALITY_TRAITS[Math.floor(Math.random() * PERSONALITY_TRAITS.length)];
  const ages = ['20岁', '25岁', '30岁', '35岁', '40岁', '45岁', '50岁', '17岁'];
  const age = ages[Math.floor(Math.random() * ages.length)];

  // 角色定位规则
  let role: string;
  if (index === 0) {
    role = 'protagonist';     // 第一个角色 = 主角
  } else if (index === 1) {
    role = total > 2 ? 'antagonist' : 'supporting';  // 第二个 = 反派/配角
  } else {
    role = 'supporting';      // 其余 = 配角
  }

  // 角色目标随题材变化
  const goalMap: Record<string, string> = {
    'comedy': '找到真正的快乐',
    'tragedy': '与命运抗争',
    'mystery': '揭开真相',
    'sci-fi': '拯救人类的未来',
    'romance': '找到真爱',
    'history': '改变国家的命运',
    'fantasy': '守护世界的和平',
    'horror': '活着离开',
    'action': '拯救世界',
  };
  goals = goalMap[genre] || '寻找内心的平静';

  return {
    id: charId(),
    name, age,
    gender: isMale ? '男' : '女',
    personality: trait,
    appearance: '',
    background: '出生于普通家庭,' + name + '从小就展现出' + trait + '的特质。',
    goals, role,
  };
}

姓名库的设计:分别维护 15 个男名和 15 个女名,名字风格兼顾现代感与古风(如"陆沉舟"“慕容晴”)。这样的规模足够提供多样性,又不会因为库太大导致"撞名"概率过低——实际上,用户更可能记住出现过的角色名。

性格库:20 个性格词条,覆盖正面(“善良温柔”)到负面(“高傲自负”),以及中性描述(“神秘莫测”)。

6.3 场景地点与时段的题材适配

每个题材有专属的 6 个场景地点,确保风格一致性:

const LOCATIONS: Record<string, string[]> = {
  'comedy':  ['咖啡馆', '学校教室', '写字楼', '菜市场', '地铁站', '婚宴现场'],
  'tragedy': ['废弃医院', '老宅', '悬崖边', '墓地', '雨夜街头', '法院'],
  'mystery': ['古堡', '地下室', '密室', '警局', '废弃工厂', '图书馆'],
  'sci-fi':  ['太空站', '实验室', '虚拟世界', '外星殖民地', '时间管理局', 'AI核心舱'],
  'romance': ['海边', '樱花树下', '书店', '音乐厅', '天台', '古镇小巷'],
  'history': ['宫殿', '战场', '驿站', '朝堂', '军营', '古城墙'],
  'fantasy': ['魔法森林', '龙巢', '精灵神殿', '地下城', '天空之城', '巫师塔'],
  'horror':  ['闹鬼别墅', '地下室', '荒村', '精神病院', '迷雾森林', '废弃教堂'],
  'action':  ['摩天大楼', '地下拳场', '高速公路', '码头', '核电站', '驾驶舱'],
  'literary':['书房', '花园', '河边', '美术馆', '旧书店', '阁楼'],
};

时段固定为 7 个:['清晨', '正午', '午后', '黄昏', '夜晚', '深夜', '黎明']

6.4 对话模板系统

每个题材包含 5 组对话模板,每组 2-3 句台词。以喜剧为例:

'comedy': [
  ['你认真的?这主意比早餐还离谱。', '有时候最离谱的就是最棒的!'],
  ['我不是在笑你,我是在笑这个世界。', '那我们也够惨的,活在一个笑话里。'],
  ['你看我像是有$SKILL的人吗?', '不像,但你像是有$SKILL的潜力。'],
  ['钱不是万能的。', '但没钱是万万不能的——这句话本身就没钱。'],
  ['你为什么要帮我?', '因为看你倒霉的样子……实在太有喜感了。'],
],

$SKILL 是一个变量占位符,在运行时被替换为随机技能名:

function getRandomSkill(): string {
  const skills = ['画画', '唱歌', '编程', '厨艺', '运动', '写作', '弹琴', '跳舞', '做生意', '打架'];
  return skills[Math.floor(Math.random() * skills.length)];
}

// 替换后效果:"你看我像是有编程的人吗?"

6.5 动作描述模板

每个题材 6 个动作模板,支持 #NAME##NAME2# 两个角色名变量:

'horror': [
  '#NAME#缓缓转过头——什么都没有。但当#NAME#转回来时,那张脸就在面前。',
  '#NAME#听到走廊尽头的房间传来轻轻的脚步声。',
  '#NAME#的呼吸在冷空气中化作白雾,虽然暖气明明开着。',
  '#NAME#看着镜子,镜中的#NAME#露出一个#NAME#没有做的表情。',
  '#NAME#用手机照亮黑暗,光线照到了一双脚——悬在空中。',
  '#NAME#猛地醒来,喘着粗气。但那声音还在——从现实的门外传来。',
],

模板替换函数通过字符串的 replace 方法实现:

function replaceNames(template: string, chars: ScriptCharacter[]): string {
  let result = template;
  if (chars.length > 0) {
    result = result.replace(/#NAME#/g, chars[0].name);
  }
  if (chars.length > 1) {
    result = result.replace(/#NAME2#/g, chars[1].name);
  } else if (chars.length > 0) {
    result = result.replace(/#NAME2#/g, chars[0].name);
  }
  return result;
}

6.6 正文生成流水线

function generateScriptContent(
  genre: string, style: string,
  chars: ScriptCharacter[], scenes: SceneInfo[],
  includeStageDirections: boolean,
): string {
  const lines: string[] = [];
  const dialogues = DIALOGUE_TEMPLATES[genre] || DIALOGUE_TEMPLATES['comedy'];
  const actions = ACTION_TEMPLATES[genre] || ACTION_TEMPLATES['comedy'];

  // STEP 1: 标题
  lines.push('《' + getGenreTitle(genre) + '》');

  // STEP 2: 角色表
  lines.push('【角色表】');
  for (let ci = 0; ci < chars.length; ci++) {
    lines.push(getRoleLabel(chars[ci]) + ' ' + chars[ci].name + ' ...');
  }

  // STEP 3: 逐场景生成
  for (let si = 0; si < scenes.length; si++) {
    // 幕分隔符
    if (scene.actNumber !== currentAct) { ... }

    // 场景标题
    lines.push(scene.sceneNumber + '  ' + scene.title);

    // 交替生成动作 + 对话(3-6 轮次)
    for (let step = 0; step < 3 + random(4); step++) {
      if (step % 2 === 0 && includeStageDirections) {
        // 从动作库随机选一条,替换角色名
      }
      // 从对话库随机选一组,分配说话者
    }

    if (includeStageDirections) lines.push('(灯暗)');
  }

  // STEP 4: 结尾
  lines.push('【全剧终】');
  return lines.join('\n');
}

场景内生成逻辑:每个场景生成 3-6 步(3 + random(4)),交替输出动作描述和对话。这样既不会让场景太短显得空洞,也不会太长导致阅读疲劳。

6.7 续写功能

export function continueScript(existingScript: Script, additionalScenes: number): Script {
  // 1. 复制已有场景
  const newScenes: SceneInfo[] = [];
  for (let i = 0; i < existingScript.scenes.length; i++) {
    newScenes.push(existingScript.scenes[i]);
  }

  // 2. 追加新场景
  for (let i = 0; i < additionalScenes; i++) {
    newScenes.push({
      // 新场景 ID、新的标题
      actNumber: existingScript.actCount,
      sceneNumber: baseNum + i + 1,
      // ...
    });
  }

  // 3. 用全部场景(新旧)重新生成正文
  const fullContent = generateScriptContent(
    existingScript.genre, existingScript.style,
    existingScript.characters, newScenes, true,
  );

  // 4. 返回更新后的 Script(保留原 id/createdAt)
  return { /* ... */ };
}

注意:续写是以全量重生成的方式工作的——不是追加文本,而是用扩增后的场景列表重新调用 generateScriptContent。这样新旧场景的对话风格一致,但代价是之前的台词内容会发生变化。

6.8 导出功能

支持 TXT 和 Markdown 两种格式:

export function exportScript(script: Script, format: string): string {
  if (format === 'txt') {
    return script.content;  // 直接返回正文
  }
  // Markdown 格式——结构化导出
  let md = '# 《' + script.title + '》\n\n';
  md += '> **题材**:' + genreName(script.genre) + '\n';
  md += '> **角色数**:' + script.characters.length + '\n';
  md += '---\n\n## 角色表\n\n';
  for (let i = 0; i < script.characters.length; i++) {
    md += '- **' + c.name + '**(' + c.age + '):' + c.personality + '\n';
  }
  md += '\n---\n\n## 剧本正文\n\n' + script.content;
  md += '\n\n*由 AI剧本 App 生成*\n';
  return md;
}

配合 pasteboard API 实现一键导出到剪贴板:

exportText(text: string): void {
  try {
    const data = pasteboard.createData({ 'text/plain': text });
    pasteboard.getSystemPasteboard().setData(data);
    this.showToastMsg('已复制到剪贴板');
  } catch (_) {
    this.showToastMsg('导出失败');
  }
}

7. 数据持久化层:AppStorage 实战

7.1 AppStorage 简介

HarmonyOS 的 AppStorage 是一个全局的、响应式的键值存储系统。与传统的 SharedPreferences 或 localStorage 相比,它的特点在于:

  • 全局访问:任何组件、任何模块都可以读写
  • 响应式绑定:当存储值变化时,绑定的 UI 会自动更新
  • 同步 API:读写操作是同步的,无需 await

7.2 存储层设计

const STORAGE_KEY_SCRIPTS = 'ai_script_list_v3';

export function loadScripts(): Script[] {
  try {
    const data = AppStorage.get<string>(STORAGE_KEY_SCRIPTS);
    if (data) {
      const parsed: object = JSON.parse(data);
      if (Array.isArray(parsed)) {
        const result: Script[] = [];
        for (let i = 0; i < parsed.length; i++) {
          result.push(parsed[i] as Script);
        }
        return result;
      }
    }
  } catch (_) {}
  return [];
}

版本化 KeySTORAGE_KEY_SCRIPTS 使用 _v3 后缀。当数据模型发生不兼容变更时(比如新增字段、修改字段类型),只需递增版本号,旧数据会被自动忽略(JSON.parse 返回空数组)。这是最简单的数据迁移方案。

7.3 CRUD 操作——全量更新模式

所有写操作都遵循"读取全量 → 修改 → 写入全量"的模式:

export function toggleFavorite(scriptId: string): Script[] {
  const list = loadScripts();           // 1. 读取全量
  const result: Script[] = [];          // 2. 构建新数组
  for (let i = 0; i < list.length; i++) {
    const s = list[i];
    if (s.id === scriptId) {
      // 3. 修改目标项(显式复制所有字段)
      const updated: Script = {
        id: s.id, title: s.title, genre: s.genre, style: s.style,
        logline: s.logline, prompt: s.prompt, content: s.content,
        characters: s.characters, scenes: s.scenes,
        createdAt: s.createdAt, updatedAt: s.updatedAt,
        wordCount: s.wordCount, isFavorite: !s.isFavorite, actCount: s.actCount,
      };
      result.push(updated);
    } else {
      result.push(s);
    }
  }
  saveScripts(result);                  // 4. 写入全量
  return result;
}

这种模式在数据量不大(通常 < 100 条记录)时完全够用。如果未来需要扩展到数千条,可以考虑引入索引或分页机制。

7.4 统计聚合

export function getStats(): ScriptStats {
  const list = loadScripts();
  let totalWords = 0;
  let favoriteCount = 0;
  const genreMap: Record<string, number> = {};

  for (let i = 0; i < list.length; i++) {
    const s = list[i];
    totalWords += s.wordCount;
    if (s.isFavorite) favoriteCount++;
    genreMap[s.genre] = (genreMap[s.genre] || 0) + 1;
  }

  // 按更新时间排序取前 5
  const sorted = [...list];
  sorted.sort((a, b) => b.updatedAt - a.updatedAt);
  const recentScripts = sorted.slice(0, 5);

  return {
    totalScripts: list.length,
    totalWords, favoriteCount,
    genreDistribution: Object.keys(genreMap).map(k => ({ genre: k, count: genreMap[k] })),
    recentScripts,
  };
}

统计函数在首页 aboutToAppear 和每次 CRUD 操作后调用,刷新首页的统计摘要。


8. UI 层:ArkUI 声明式界面构建

8.1 设计语言与色彩体系

Token 色值 用途
深色主色 #1A1A2E 导航栏、按钮、标题文字
紫色辅色 #5B6ABF 标签、链接、操作按钮
页面背景 #F5F6FA 全局页面背景
绿色成功 #2ED573 完成状态、续写标签
橙色警告 #FF9F43 角色计数、风格标签
红色危险 #FF6B81 删除按钮

8.2 Hero 区域设计

首页顶部采用深色背景的品牌区域,包含 App 名称、副标题和三个快捷入口:

@Builder
HomePage() {
  Column() {
    // Hero 区域
    Column() {
      Row() {
        Column() {
          Text('AI 剧本').fontSize(28).fontWeight(FontWeight.Bold).fontColor(Color.White)
          Text('创作你的故事').fontSize(13).fontColor('rgba(255,255,255,0.7)')
        }
        Text('🎭').fontSize(36)
      }
      Row() {
        this.QBtn('✨', '新剧本')
        this.QBtn('📋', '剧本库')
        this.QBtn('♥', '收藏')
      }
    }
    .backgroundColor('#1A1A2E')
    .borderRadius({ bottomLeft: 24, bottomRight: 24 })
    // ...
  }
}

borderRadius 只给底部两个角设置圆角(bottomLeft / bottomRight),这是 ArkUI 支持的非对称圆角语法。

8.3 题材网格 Grid

使用 Grid 组件实现 5 列等宽的题材选择器:

Grid() {
  ForEach(GENRE_LIST, (g: GenreItem) => {
    GridItem() {
      Column() {
        Text(g.icon).fontSize(28)
        Text(g.name).fontSize(12).fontColor('#444')
        Text(g.desc).fontSize(9).fontColor('#999')
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .backgroundColor(Color.White).borderRadius(12)
      .onClick(() => {
        this.genGenreIdx = this.getGenreIndex(g.id);
        this.page = 'generate';
      })
    }
  })
}
.columnsTemplate('1fr 1fr 1fr 1fr 1fr')
.rowsGap(8).columnsGap(8).padding({ left: 16, right: 16 })

columnsTemplate 使用 fr(fraction)单位实现等分,类似 CSS Grid 的 1fr

8.4 水平滚动选择器

题材和风格的选择器都使用了嵌套的 Scroll(ScrollDirection.Horizontal) 实现水平滚动:

Scroll() {
  Row() {
    ForEach(STYLE_LIST, (s: StyleItem, i: number) => {
      Text(s.name)
        .fontColor(i === this.genStyleIdx ? Color.White : '#666')
        .backgroundColor(i === this.genStyleIdx ? '#1A1A2E' : '#F0F0F0')
        .borderRadius(16)
        .padding({ left: 14, right: 14, top: 6, bottom: 6 })
        .onClick(() => { this.genStyleIdx = i; })
    })
  }
}
.scrollable(ScrollDirection.Horizontal).width('100%').height(36)

选中项使用反色高亮(白色文字 + 深色背景),未选中项使用灰色背景。这种"胶囊"样式的选择器在移动端非常常见。

8.5 折叠高级设置面板

高级设置通过 @State genShowAdvanced 控制展开/折叠:

Row() {
  Text('⚙️ 高级设置')
  Blank()
  Text(this.genShowAdvanced ? '收起 ▲' : '展开 ▼')
}
.onClick(() => { this.genShowAdvanced = !this.genShowAdvanced; })

if (this.genShowAdvanced) {
  this.GenAdvancedSettings()
}

GenAdvancedSettings 是一个独立的 @Builder,包含角色数、场次数、幕数的 +/- 调节器,以及复选框选项和自定义提示词输入框。

8.6 搜索与筛选

剧本列表页集成了搜索和题材筛选功能:

getFilteredList(): Script[] {
  const all = loadScripts();
  let tempList: Script[] = [];

  // 收藏筛选 或 题材筛选 或 全部
  if (this.listShowFav) {
    for (let i = 0; i < all.length; i++) {
      if (all[i].isFavorite) tempList.push(all[i]);
    }
  } else if (this.listTab === 'all') {
    // 全部
    for (let i = 0; i < all.length; i++) { tempList.push(all[i]); }
  } else {
    for (let i = 0; i < all.length; i++) {
      if (all[i].genre === this.listTab) tempList.push(all[i]);
    }
  }

  // 关键词搜索(大小写不敏感)
  if (this.listSearch.trim()) {
    const kw = this.listSearch.toLowerCase();
    const filtered: Script[] = [];
    for (let i = 0; i < tempList.length; i++) {
      const s = tempList[i];
      if (s.title.toLowerCase().includes(kw) ||
          s.logline.toLowerCase().includes(kw)) {
        filtered.push(s);
      }
    }
    return filtered;
  }
  return tempList;
}

这里使用 includes 而非正则表达式进行搜索,确保对中文内容的正确匹配,同时避免正则注入风险。


9. 组件化设计与回调传递

9.1 从内联 @Builder 到独立组件

在项目演进过程中,我们将部分 UI 从 @Builder 内联实现重构为独立的 @Component 组件。以剧本卡片为例:

@Builder 内联版本(旧):

@Builder
ScriptCardSmall(script: Script) {
  Row() { /* 卡片 UI */ }
  .onClick(() => { /* 跳转详情 */ })
}

独立组件版本(新):

@Component
export struct ScriptCard {
  @Prop script: Script = { /* 默认值 */ };
  @State private isFav: boolean = false;
  onTap: (script: Script) => void = () => {};
  onFav: (scriptId: string) => void = () => {};

  build() {
    Column() {
      // 卡片内容
    }
    .onClick(() => { this.onTap(this.script); })
  }
}

9.2 ArkTS 中的回调传递模式

在 ArkTS 中,不能在组件构造后链式调用 setter 方法——因为组件构造函数返回 void。正确的做法是直接在构造函数中传递回调:

// ❌ 错误模式——ArkTS 编译器会报错
ScriptCard({ script: item })
  .setOnTap((script) => { ... })
  .setOnFav((id) => { ... })

// ✅ 正确模式——在构造参数中直接传递
ScriptCard({
  script: item,
  onTap: (script: Script) => {
    this.detailScript = script;
    this.page = 'detail';
  },
  onFav: (scriptId: string) => {
    this.scripts = toggleFavorite(scriptId);
    this.refreshStats();
  }
})

关键技巧:将回调定义为组件类的公开属性(不加 private),ArkUI 框架会自动将这些属性识别为可构造参数。

// ScriptCard 组件定义
export struct ScriptCard {
  @Prop script: Script = {} as Script;
  onTap: (script: Script) => void = () => {};   // 公开属性,可构造传参
  onFav: (scriptId: string) => void = () => {}; // 同上
}

9.3 ScriptResultCard 组件

生成结果卡片组件的完整实现:

@Component
export struct ScriptResultCard {
  @Prop script: Script = {} as Script;
  onRegenerate: () => void = () => {};   // "重新生成" 回调
  onSave: (script: Script) => void = () => {};  // "保存" 回调

  build() {
    Column() {
      // 完成状态
      Row() {
        Text('✅ 生成完成').fontColor('#2ED573')
      }

      // 统计数据(角色数、场景数、幕数、字数)
      Row() {
        this.StatItem(this.script.characters.length.toString(), '角色')
        this.StatItem(this.script.scenes.length.toString(), '场景')
        this.StatItem(this.script.actCount.toString(), '幕')
        this.StatItem(this.getWordCountStr(this.script.wordCount), '字数')
      }

      // 操作按钮
      Row() {
        Button() { Text('🔄 重新生成') }
          .onClick(() => { this.onRegenerate(); })
        Button() { Text('💾 保存并查看') }
          .onClick(() => { this.onSave(this.script); })
      }
    }
  }

  @Builder
  StatItem(value: string, label: string) {
    Column() {
      Text(value).fontSize(20).fontWeight(FontWeight.Bold)
      Text(label).fontSize(10)
    }
  }
}

10. 状态管理深入:@State / @Prop / @Link

10.1 三级响应式状态体系

HarmonyOS ArkUI 提供三种状态装饰器,覆盖从本地状态到跨组件共享的全部场景:

装饰器 作用域 特点
@State 当前组件 私有状态,变化触发 UI 重绘
@Prop 父→子单向 父组件传入,子组件可读不可写(写会同步回父?实际上 @Prop 是单向的,子组件修改不会同步到父)
@Link 父↔子双向 父子组件共享同一状态,任何一方修改都会同步

10.2 @State 实践

本项目中 @State 是使用最广泛的装饰器。所有页面级状态都定义在 AppMain struct 中:

@State private page: string = 'home';          // 当前页面
@State private scripts: Script[] = [];          // 剧本列表
@State private statsStr: string = '加载中...';   // 统计摘要

// 生成页状态
@State private genGenreIdx: number = 0;         // 选中的题材索引
@State private genStyleIdx: number = 0;         // 选中的风格索引
@State private genLoading: boolean = false;     // 生成加载中
@State private genResult: Script | null = null; // 生成结果

// 列表页状态
@State private listTab: string = 'all';         // 题材筛选
@State private listSearch: string = '';         // 搜索关键词

// 详情页状态
@State private detailScript: Script | null = null;
@State private detailSceneIdx: number = 0;

设计原则:所有跨 @Builder 共享的状态都定义为 @State,确保任何一个 @Builder 中对状态的修改都能触发其他 @Builder 的 UI 更新。

10.3 @Prop 实践

@Prop 用于父组件向子组件传递数据:

// 父组件(111.ets)
ScriptCard({ script: item })

// 子组件(ScriptCard.ets)
@Component
export struct ScriptCard {
  @Prop script: Script = {} as Script;
  // ...
}

对于需要默认值的 @Prop,必须提供一个完整的默认对象。这里使用了类型断言 as Script 来告诉编译器这个对象字面量符合 Script 接口:

@Prop script: Script = {
  id: '', title: '', genre: 'comedy', style: 'classic',
  logline: '', prompt: '', content: '',
  characters: [], scenes: [],
  createdAt: 0, updatedAt: 0, wordCount: 0,
  isFavorite: false, actCount: 3,
} as Script;

10.4 状态更新触发 UI 重绘的注意事项

直接修改数组元素不会触发重绘

// ❌ 不会触发 UI 更新
this.scripts[0].title = '新标题';

// ✅ 会触发 UI 更新——创建新数组替换旧数组
const newList: Script[] = [];
for (let i = 0; i < this.scripts.length; i++) {
  if (i === 0) {
    newList.push({ ...this.scripts[i], title: '新标题' });
  } else {
    newList.push(this.scripts[i]);
  }
}
this.scripts = newList;

这就是存储层函数全部返回新数组而非修改原数组的原因:

export function toggleFavorite(scriptId: string): Script[] {
  const list = loadScripts();
  const result: Script[] = [];  // 新数组
  // ... 构建新数组
  saveScripts(result);
  return result;  // 返回新引用
}

10.5 Toast 轻提示的实现

本项目实现了一个轻量级的 Toast 提示系统,无需引入第三方库:

@State private toastMsg: string = '';
@State private showToast: boolean = false;

showToastMsg(msg: string): void {
  this.toastMsg = msg;
  this.showToast = true;
  setTimeout(() => { this.showToast = false; }, 2000);
}

// 在 build 中渲染
if (this.showToast) {
  Text(this.toastMsg)
    .fontSize(14).fontColor(Color.White)
    .padding({ left: 24, right: 24, top: 10, bottom: 10 })
    .backgroundColor('rgba(0,0,0,0.8)').borderRadius(20)
    .position({ x: '50%', y: '88%' }).translate({ x: '-50%' }).zIndex(999)
}

11. 用户交互与体验优化

11.1 加载状态与模拟延迟

为了营造"AI 正在思考"的体验,在模板引擎实际只需几十毫秒的前提下,特意添加了 1200ms 的模拟延迟:

doGenerate(): void {
  this.genLoading = true;
  this.genShowResult = false;
  setTimeout(() => {
    const script = generateScript({ /* params */ });
    this.genResult = script;
    this.genShowResult = true;
    this.genLoading = false;
  }, 1200);  // 模拟 AI 生成延迟
}

生成按钮在加载期间显示 LoadingProgress 并禁用点击:

Button() {
  if (this.genLoading) {
    LoadingProgress().width(22).height(22).color(Color.White)
  } else {
    Text('🚀 AI 生成剧本')
  }
}
.enabled(!this.genLoading)

11.2 确认对话框

删除操作使用系统 AlertDialog 提供二次确认:

AlertDialog.show({
  title: '删除剧本',
  message: '确定删除《' + sc.title + '》吗?',
  primaryButton: { value: '取消', action: () => {} },
  secondaryButton: {
    value: '删除', fontColor: '#FF6B81',
    action: () => {
      this.scripts = deleteScript(sc.id);
      this.page = 'list';
      this.showToastMsg('已删除');
    },
  },
});

11.3 列表空态设计

当用户还没有剧本时,展示友好的空态提示和引导操作:

if (this.scripts.length === 0) {
  Column() {
    Text('📝').fontSize(48)
    Text('还没有剧本,点击上方开始创作').fontSize(14).fontColor('#999')
      .margin({ top: 8 })
  }
  .width('100%').padding({ top: 40, bottom: 40 })
  .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}

11.4 收藏反馈

收藏按钮的即时视觉反馈:

Text(this.isFav ? '❤️' : '🤍').fontSize(18)
  .onClick(() => {
    this.isFav = !this.isFav;
    this.onFav(this.script.id);
  })

点击后图标立即从空心变为实心,同时触发持久化操作。这种即时视觉反馈(乐观更新)让用户感觉操作响应迅速。


12. 测试策略与质量保障

12.1 测试架构

测试文件结构:

entry/src/
├── test/
│   ├── List.test.ets           # 集成测试
│   └── LocalUnit.test.ets      # 单元测试
├── ohosTest/
│   └── ets/test/
│       └── Ability.test.ets    # 鸿蒙原生测试
└── mock/
    └── mock-config.json5       # Mock 配置

12.2 单元测试用例

覆盖 AI 引擎的核心逻辑:

// ScriptGeneratorTest
generateScript_should_return_valid_script
  → 验证角色数 = characterCount
  → 验证场景数 = sceneCount
  → 验证幕数 = actCount
  → 验证 wordCount > 100
  → 验证 isFavorite = false

generateScript_all_genres
  → 验证全部 10 种题材都能正常生成

continueScript_should_add_scenes
  → 验证续写后场景数 = 原数 + additionalScenes
  → 验证 id 不变、updatedAt 更新

exportScript_txt_format → 验证 TXT 格式正确
exportScript_markdown_format → 验证 Markdown 包含标题和角色表

genId_should_be_unique
  → 验证 100 次生成的 ID 无重复

12.3 集成测试用例

// ScriptIntegrationTest
create_and_load_script
  → 创建剧本 → 检查列表中存在

toggle_favorite
  → 收藏 → 验证 isFavorite = true → 取消收藏 → 验证 isFavorite = false

delete_script
  → 删除 → 验证 getScript 返回 null

get_stats
  → 创建前后对比 stats.totalScripts 增加

12.4 Mock 策略

使用 @ohos/hamock 模拟系统 API:

// mock-config.json5
{
  "mocks": {
    "pasteboard": {
      "createData": { "return": {} },
      "getSystemPasteboard": {
        "return": { setData: () => true }
      }
    }
  }
}

13. 构建与预览配置

13.1 Hvigor 构建配置

// hvigor/hvigor-config.json5
{
  modelVersion: "6.1.0",
  execution: {
    // "analyze": "normal",
    // "daemon": true,
    // "incremental": true,
  },
}

13.2 模块配置

// entry/build-profile.json5
{
  apiType: "stageMode",
  buildOption: {
    arkOptions: {
      strictMode: "strict"
    }
  },
  targets: [
    {
      name: "default",
      runtimeOS: "HarmonyOS"
    }
  ]
}

13.3 页面路由注册

// resources/base/profile/main_pages.json
{
  profile: {
    main_pages: {
      src: ["pages/111"]
    }
  }
}

HarmonyOS 的页面路由基于文件名注册。"pages/111" 对应 ets/pages/111.ets

13.4 预览构建常见问题

预览构建(Preview)与真机构建共享大部分流程,但有以下区别:

  • 预览使用 FakeUIAbility 而非真实 Ability
  • 预览无法调用部分系统 API(如 camera)
  • 预览的构建产物输出到 entry/.preview/ 目录

14. 踩坑记录与 ArkTS 合规要点

14.1 错误 1:组件回调链式调用

症状:编译错误 Property 'setOnFav' does not exist on type 'void'

根因:ArkTS 中组件构造函数的返回值是 void,不是组件实例。因此 Component({...}).setXxx() 的链式调用模式不合法。

// ❌ 错误
ScriptCard({ script: item })
  .setOnTap(() => {})
  .setOnFav(() => {})

// ✅ 正确
ScriptCard({
  script: item,
  onTap: () => {},
  onFav: () => {},
})

解决方案:将回调定义为组件的公开属性(不加 private),在构造时直接传入。

14.2 错误 2:索引访问类型

症状:编译错误 Indexed access types are not supported (arkts-no-aliases-by-index)

根因Script['characters'] 这种通过索引访问类型的语法在 ArkTS 中不被支持。

// ❌ 错误
getCharNames(chars: Script['characters']): string { }

// ✅ 正确
getCharNames(chars: ScriptCharacter[]): string { }

14.3 错误 3:! 非空断言的使用限制

! 非空断言在 ArkTS 中是允许的,但必须在编译器可以验证的空值检查之后使用:

// 正确(先判断非 null,再使用 !)
if (this.genShowResult && this.genResult !== null) {
  ScriptResultCard({ script: this.genResult!, ... })
}

14.4 错误 4:@Builder 内逻辑限制

@Builder 函数内部不允许声明变量、执行赋值操作或使用循环语句。所有逻辑计算应提取到 struct 的普通方法中:

// ❌ 错误
@Builder
MyView() {
  const x = this.calculate();
  Text(x.toString())
}

// ✅ 正确
@Builder
MyView() {
  Text(this.getCalculatedValue())
}

getCalculatedValue(): string {
  return this.calculate().toString();
}

14.5 错误 5:数组/对象引用不变不触发重绘

@State 通过引用比较(=== 或 Object.is)判断是否变化。修改数组元素或对象属性而不改变引用,不会触发 UI 重绘:

// ❌ 不会触发重绘
this.scripts[0].title = '新标题';

// ✅ 创建新数组
const newList: Script[] = [];
for (let i = 0; i < this.scripts.length; i++) {
  newList.push(this.scripts[i]);
}
newList[0] = { ...this.scripts[0], title: '新标题' };
this.scripts = newList;

14.6 设计决策日志

决策点 选项 选择 理由
页面路由 Navigation vs 条件渲染 条件渲染 小应用,状态共享简单
AI 引擎 LLM API vs 模板引擎 模板引擎 离线可用、零成本、可控
数据存储 RDB vs AppStorage AppStorage 数据结构简单,查询需求低
ID 生成 UUID vs 时间戳+随机 时间戳+随机 无外部依赖
状态管理 全局 Store vs @State @State 规模小,无需 Redux 模式
回调传递 Setter 链式 vs 构造参数 构造参数 ArkTS 语法限制
导出格式 PDF vs TXT vs MD TXT + MD 实现简单

15. 总结与展望

15.1 项目回顾

通过本文,我们完整走了一遍 HarmonyOS API 24 上 AI 剧本创作 App 的开发流程:

  1. 类型系统设计:定义了 ScriptScriptCharacterSceneInfo 等核心接口,构建了强类型的 ArkTS 应用
  2. AI 生成引擎:基于模板 + 随机组合的离线生成器,支持 10 种题材 × 5 种风格
  3. 数据持久化:使用 AppStorage 实现剧本的 CRUD 操作和统计聚合
  4. UI 构建:通过 ArkUI 声明式语法实现 6 个页面的完整交互
  5. 组件化:提取 ScriptCardScriptResultCard 为独立组件,通过构造参数传递回调
  6. 测试覆盖:9 个测试用例覆盖引擎核心逻辑和存储操作

15.2 可扩展方向

1. 接入 LLM API
将模板引擎替换或增强为调用大语言模型 API,实现真正的 AI 创作。可以设计为"快速模式(模板)"和"深度模式(LLM)"两种选项。

2. 云端同步
基于 HarmonyOS 的云开发能力,实现多设备之间的剧本同步。使用 @kit.NetworkKit 进行云端数据存取。

3. 分布式协作
利用 HarmonyOS 的分布式框架,让多个用户在同一剧本上进行实时协作编辑。

4. 富文本排版
当前剧本内容使用纯文本渲染。可以引入富文本组件,支持字体大小、颜色、粗体等排版功能,让剧本阅读体验更佳。

5. AI 配图
根据剧本的场景描述,使用 AI 绘图模型自动生成场景插画。这可以结合 media API 在 App 内直接展示。

6. 语音朗读
集成 TTS(Text-to-Speech)能力,让 App 朗读剧本对话,模拟"听剧"体验。

15.3 结语

构建 AI 辅助的创意工具,技术实现只是第一步。真正重要的是理解创作者的 workflow,在合适的位置融入 AI 能力。

本项目中的"AI"并非真正的深度学习模型,而是一个精心设计的模板+随机引擎——但这恰恰说明了一个重要观点:AI 辅助体验的核心不在于模型有多强,而在于产品设计能否降低用户的创作门槛,激发他们的表达欲望。

当用户选定题材、调整参数、点击"AI 生成",看到属于自己的剧本跃然屏上时,那种"创作完成"的成就感,无论是来自大模型还是模板引擎,都不会有区别。


附录

A. 关键源码文件索引

文件 行数 职责
ets/pages/111.ets 913 主页面(6 个子页面路由)
ets/model/ScriptGenerator.ets 601 AI 剧本生成引擎
ets/model/ScriptStorage.ets 188 AppStorage 持久化
ets/model/ScriptTypes.ets 206 类型定义 + 常量 + 工具函数
ets/components/ScriptCard.ets 56 剧本卡片组件
ets/components/ScriptResultCard.ets 77 生成结果卡片组件
ets/entryability/EntryAbility.ets 39 Ability 生命周期

B. 依赖清单

@ohos/hamock@1.0.0        # 测试 Mock 框架
@ohos/hypium@1.0.25       # 测试运行框架
@kit.BasicServicesKit      # 基础服务(pasteboard 等)
@kit.ArkData               # 数据存储 API
@kit.AbilityKit            # Ability 框架
@kit.PerformanceAnalysisKit # 日志(hilog)

C. 参考资源


本文基于 demo012 项目中的 AI 剧本模块编写
平台: HarmonyOS API 24 | 语言: ArkTS | 构建: Hvigor 6.1.0
许可: MIT License
最后更新: 2026-06-20

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐