基于 HarmonyOS 的 AI 辅助创意应用开发实战 —— 从零构建 AI 剧本生成 App



基于 HarmonyOS 的 AI 辅助创意应用开发实战 —— 从零构建 AI 剧本生成 App
作者:duluo
平台: HarmonyOS (ArkTS) + API 24
源码: demo012 - AI 剧本 App
字数: ≈ 10,000 字
阅读时间: 25 分钟
目录
- 引言:AI 时代的创意工具
- 项目概览与技术选型
- HarmonyOS 开发环境搭建与 ArkTS 语言特性
- 整体架构设计
- 类型系统与数据模型设计
- UI 层:ArkTS 声明式 UI 与组件化实践
- AI 剧本生成引擎设计与实现
- 数据持久化:AppStorage 本地存储方案
- 用户交互与体验优化
- 多模块复用设计:盲盒贴纸、动画与 AR 识别
- 性能优化与 ArkTS 合规实践
- 测试策略与质量保障
- 构建与打包部署
- 开发心得与踩坑记录
- 总结与展望
1. 引言:AI 时代的创意工具
2024-2025 年是 AI 技术爆发式落地的两年。大语言模型(LLM)不仅在代码生成、文档撰写等"生产力"场景大放异彩,在创意写作、剧本创作等"想象力"领域同样展现出惊人的潜力。然而,大多数 AI 写作工具仍停留在 Web 端或桌面端,移动端 + AI 原生的创意工具体验尚有巨大空白。
本文分享的 AI 剧本 App(项目代号 aiplay,API 24)正是一次将 AI 内容生成能力与 HarmonyOS 原生移动体验深度融合的实践。它让用户可以在手机上:
- 选择题材(喜剧/悲剧/悬疑/科幻/爱情/历史/奇幻/恐怖/动作/文艺)
- 选择叙事风格(经典叙事/现代派/极简风/诗意风/戏剧性)
- 设置角色数、场次数、幕数等高级参数
- 一键生成完整的剧本(含角色表、场景、对话、舞台说明)
- 管理剧本库、编辑角色、续写、导出分享
全文将围绕该项目的完整开发过程,系统阐述如何在 HarmonyOS 平台(API 24)上,使用 ArkTS 语言构建一个 AI 辅助的创意内容生成应用。无论你是 HarmonyOS 新手,还是有经验的移动端开发者,都能从中获得实用的架构思路和编码技巧。
2. 项目概览与技术选型
2.1 项目功能矩阵
| 模块 | 功能 | 技术要点 |
|---|---|---|
| AI 剧本生成 | 题材/风格选择、参数配置、自动生成 | 模板引擎 + 随机算法 |
| 剧本管理 | 剧本库列表、搜索、收藏、删除 | AppStorage 持久化 |
| 角色管理 | 角色 CRUD、角色卡展示 | 表单校验、响应式 UI |
| AI 续写 | 在已有剧本基础上追加场景 | 增量生成算法 |
| 导出分享 | 复制到剪贴板 / Markdown 导出 | pasteboard API |
| 盲盒贴纸 | 开盲盒、收藏、稀有度系统 | Preferences 存储 |
| AR 饮食识别 | 食物识别、营养记录 | Camera + AI |
| 定格动画 | 照片序列播放 | 帧动画引擎 |
| 游戏剪辑 | 高光时刻裁剪 | 时间轴交互 |
2.2 技术栈
| 层级 | 技术选型 | 版本/说明 |
|---|---|---|
| 语言 | ArkTS | HarmonyOS 原生声明式语言 |
| UI 框架 | ArkUI | 声明式 UI、@Component/@Builder |
| 状态管理 | @State / @Link / @Prop / @Builder | 组件级响应式状态 |
| 持久化 | AppStorage + Preferences | 轻量级本地 KV 存储 |
| 构建工具 | Hvigor | HarmonyOS 构建系统 6.1.0+ |
| 目标 API | API 24 | HarmonyOS SDK |
| 包名 | com.atomgit.aiplay |
单 Entry 模块 |
2.3 为什么选 HarmonyOS?
HarmonyOS 的 ArkTS 语言结合 ArkUI 框架,提供了与 SwiftUI / Jetpack Compose 相似的声明式 UI 开发体验,但又有其独特的优势:
- @Builder 装饰器:支持将 UI 片段封装为可复用的构建函数,比 Compose 的 @Composable 更灵活
- @State + @Link + @Prop 三级响应式状态体系,粒度控制清晰
- AppStorage 全局存储无需手动序列化,开箱即用
- 一键多设备适配:一套代码可运行在手机、平板、折叠屏上
- 无 any/unknown 类型约束:ArkTS 强制类型安全,减少运行时错误
3. HarmonyOS 开发环境搭建与 ArkTS 语言特性
3.1 环境准备
API 24 对应 HarmonyOS SDK 的较新版本。推荐使用 DevEco Studio(基于 IntelliJ IDEA 定制)作为 IDE。
# 项目配置关键文件
hvigor/hvigor-config.json5 # 构建配置,modelVersion: 6.1.0
build-profile.json5 # 模块构建配置
local.properties # 本地 SDK 路径
3.2 ArkTS 核心语法要点
ArkTS 是 TypeScript 的超集,在标准 TS 基础上做了若干增强和限制。本项目严格遵守了以下规则:
3.2.1 禁止解构语法
// ❌ 不合法
const { name, age } = person;
// ✅ 合法替代
const name = person.name;
const age = person.age;
3.2.2 禁止展开运算符
// ❌ 不合法
const newObj = { ...oldObj, key: 'value' };
// ✅ 合法替代——显式复制每个字段
const newObj: MyType = {
field1: oldObj.field1,
field2: oldObj.field2,
key: 'value',
};
3.2.3 禁止 any / unknown
// ❌ 不合法
let x: any;
// ✅ 合法——所有类型必须显式标注
let x: string = '';
let y: number = 0;
3.2.4 @Builder 内禁止声明变量
@Builder
MyView() {
// ❌ 不合法
const temp = someValue;
// ✅ 合法——将逻辑移到普通函数
}
// 在 struct 内定义辅助方法
getSomething(): string {
return 'result';
}
这些限制初看繁琐,但实际开发中强制了代码的整洁性和可读性,也避免了运行时因类型不确定导致的崩溃。
3.3 项目文件结构
entry/src/main/ets/
├── pages/
│ └── 111.ets # 主页面(单页面路由,5 个子页面)
├── components/
│ ├── BlindBoxItem.ets # 盲盒组件
│ ├── FrameThumb.ets # 帧缩略图组件
│ ├── PlaybackControl.ets # 播放控制组件
│ ├── ScanResultPanel.ets # AR 识别结果面板
│ ├── StickerCard.ets # 贴纸卡片(3D 效果)
│ ├── TimelineBar.ets # 视频时间轴裁剪组件
│ └── VideoPreview.ets # 视频预览组件
├── model/
│ ├── ScriptTypes.ets # 剧本类型定义 + 常量
│ ├── ScriptGenerator.ets # AI 剧本生成引擎
│ ├── ScriptStorage.ets # 剧本存储层
│ ├── CollectionManager.ets # 盲盒收藏管理器
│ └── StickerData.ets # 贴纸数据模型
├── entryability/
│ └── EntryAbility.ets # Ability 入口
└── entrybackupability/
└── EntryBackupAbility.ets # 备份扩展 Ability
值得注意的是,本项目采用单页面路由模式——整个 App 只有一个 @Entry 组件(AppMain),通过 @State page: string 状态变量控制页面切换。这种模式在小体量应用中比 Navigation 路由更简洁。
4. 整体架构设计
4.1 分层架构
┌─────────────────────────────────────────────┐
│ UI Layer (ArkUI) │
│ pages/111.ets (主页面 + 5 个子视图) │
│ components/ (7 个可复用组件) │
├─────────────────────────────────────────────┤
│ State Management Layer │
│ @State / @Link / @Prop / AppStorage │
├─────────────────────────────────────────────┤
│ Business Logic Layer │
│ ScriptGenerator (AI 生成引擎) │
│ CollectionManager (盲盒管理器) │
├─────────────────────────────────────────────┤
│ Data Access Layer │
│ ScriptStorage (AppStorage CRUD) │
│ preferences (Preferences 持久化) │
├─────────────────────────────────────────────┤
│ HarmonyOS System APIs │
│ pasteboard / preferences / media / camera │
└─────────────────────────────────────────────┘
4.2 单页面 vs 多页面路由
在 HarmonyOS 中,页面路由有 router.pushUrl() 的能力。但本项目选择了单组件多状态的方案:
@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 浮层
if (this.showToast) {
Text(this.toastMsg)
.position({ x: '50%', y: '88%' })
.translate({ x: '-50%' }).zIndex(999)
}
}
}
}
优点:
- 状态共享无需跨页面传递(所有数据在
AppMain中统一管理) - 免去路由参数序列化/反序列化开销
- 页面切换动画可精确控制(通过状态变化触发)
缺点:
- 单个文件较大(本项目
111.ets约 960 行) - 不适用于团队协作的大型项目
建议:小型应用(< 10 个页面)适合此模式;大型应用推荐 Navigation + 模块化。
5. 类型系统与数据模型设计
5.1 核心类型定义
良好的类型系统是 ArkTS 项目的基石。以下是剧本模块的核心类型体系:
// ========== 剧本类型 ==========
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;
name: string;
age: string;
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; // 内景/外景
actNumber: number;
sceneNumber: number;
summary: string;
}
// ========== 生成参数 ==========
export interface GenerateParams {
genre: string;
style: string;
logline: string;
characterCount: number;
sceneCount: number;
actCount: number;
includeStageDirections: boolean;
includeSubplots: boolean;
customPrompt: 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()(毫秒级)而非Date.now()(两者等价,但getTime()更语义化) - 加上随机数后缀避免同一毫秒内的碰撞
- 带前缀(
s_/c_/sc_)便于调试时快速识别类型
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: '细腻叙事、艺术表达' },
];
配套的查询函数使用线性查找而非 Map,因为列表很小(最大 10 项),可读性优先:
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;
}
6. UI 层:ArkTS 声明式 UI 与组件化实践
6.1 主题与色彩体系
App 采用统一的设计语言:
| Token | 值 | 用途 |
|---|---|---|
| 主色 | #1A1A2E |
导航栏、按钮、标题 |
| 辅色 | #5B6ABF |
标签、链接、图标 |
| 背景 | #F5F6FA |
页面背景 |
| 强调色 | #2ED573 |
成功、完成 |
| 警告色 | #FF9F43 |
提醒、角色标签 |
| 危险色 | #FF6B81 |
删除、警告 |
6.2 首页设计
首页包含四个区域:
- Hero 区域:品牌标识 + 三快捷入口(新剧本、剧本库、收藏)
- 题材网格:10 种题材的 Grid 布局,点击跳转生成页
- 最近剧本:最近 5 个剧本的水平列表
- 空态提示:无剧本时的引导
@Builder
HomePage() {
Column() {
// Hero 区域
Column() {
Row() {
Text('AI 剧本').fontSize(28).fontWeight(FontWeight.Bold).fontColor(Color.White)
Text('创作你的故事').fontSize(13).fontColor('rgba(255,255,255,0.7)')
}
Row() {
this.QBtn('✨', '新剧本')
this.QBtn('📋', '剧本库')
this.QBtn('♥', '收藏')
}
}
.backgroundColor('#1A1A2E')
.borderRadius({ bottomLeft: 24, bottomRight: 24 })
Scroll() {
// 题材网格
Grid() {
ForEach(GENRE_LIST, (g: GenreItem) => {
GridItem() { /* ... */ }
})
}
.columnsTemplate('1fr 1fr 1fr 1fr 1fr')
// 最近剧本
if (this.scripts.length === 0) {
// 空态
} else {
List() {
ForEach(this.scripts.slice(0, 5), (item: Script) => {
ListItem() { this.ScriptCardSmall(item) }
})
}
}
}
}
}
6.3 @Builder 的最佳实践
@Builder 是 ArkUI 最强大的特性之一。它允许将 UI 片段封装为可复用函数:
@Builder
QBtn(icon: string, label: string) {
Column() {
Text(icon).fontSize(24)
Text(label).fontSize(12).fontColor('rgba(255,255,255,0.85)')
}
.width(80).padding({ top: 10, bottom: 10 })
.backgroundColor('rgba(255,255,255,0.10)').borderRadius(14)
.onClick(() => {
if (label === '收藏') { /* ... */ }
else if (label === '剧本库') { /* ... */ }
else { this.page = 'generate'; }
})
}
关键规则:
@Builder内不能声明变量或执行逻辑计算——所有计算提前在 struct 方法中完成@Builder可以接收参数(如本例的icon和label)- 通过
this.xxx()方式调用,而非<QBtn />
6.4 生成页的复杂交互
生成页是 UI 最复杂的页面,包含:
- 题材选择:水平滚动的 Grid(点击选中 + 高亮)
- 风格选择:水平滚动的标签列表(胶囊样式)
- 故事梗概:TextArea 输入
- 高级设置:可折叠面板,内含角色数/场次数/幕数的 +/- 调节器
- 生成按钮:Loading 状态控制
- 结果卡片:生成成功后展示概要 + 保存/重新生成按钮
@Builder
GeneratePage() {
Column() {
this.NavBar('✨ 生成新剧本', true)
Scroll() {
// 题材选择
Scroll() {
Row() {
ForEach(GENRE_LIST, (g: GenreItem, i: number) => {
Column() {
Text(g.icon).fontSize(22)
Text(g.name).fontSize(11)
}
.backgroundColor(i === this.genGenreIdx ? '#EEF0FF' : '#F5F5F5')
.borderRadius(10)
.onClick(() => { this.genGenreIdx = i; })
})
}
}
// 高级设置面板(折叠)
if (this.genShowAdvanced) {
this.GenAdvancedSettings()
}
// 生成按钮
Button() {
if (this.genLoading) {
LoadingProgress().width(22).height(22).color(Color.White)
} else {
Text('🚀 AI 生成剧本')
}
}
.enabled(!this.genLoading)
.onClick(() => { this.doGenerate(); })
// 结果卡片
if (this.genShowResult && this.genResult !== null) {
this.GenResultCard(this.genResult!)
}
}
}
}
6.5 可复用组件设计
components/ 目录下的 7 个组件均为 export struct,可在其他页面中 import 使用:
| 组件 | 用途 | 核心 Props |
|---|---|---|
BlindBoxItem |
盲盒开箱交互 | boxType, onOpen |
FrameThumb |
动画帧缩略图 | frame (AnimFrame) |
PlaybackControl |
播放/暂停/步进 | isPlaying, onPlay, onPause, onStep |
ScanResultPanel |
AR 识别结果面板 | result (Link), showDetail |
StickerCard |
3D 贴纸卡片 | sticker (Prop), collected, count, compact |
TimelineBar |
视频时间轴裁剪 | project (Link) |
VideoPreview |
视频预览 | project (Link) |
以 StickerCard 为例,它展示了 compact 和 full 两种渲染模式:
@Component
export struct StickerCard {
@Prop sticker: StickerDef = { /* default */ };
@Prop collected: boolean = false;
@Prop count: number = 0;
@Prop compact: boolean = false;
build() {
Column() {
if (this.compact) {
this.CompactView()
} else {
this.FullView()
}
}
.width(this.compact ? 72 : '100%')
.animation({ duration: 300, curve: Curve.EaseOut })
}
@Builder
CompactView() {
Column() {
Column() {
Text(this.sticker.emoji).fontSize(28)
}
.width(56).height(56)
.backgroundColor(this.collected ? this.sticker.bgColor : '#f0f0f0')
.borderRadius(12)
}
}
}
7. AI 剧本生成引擎设计与实现
7.1 引擎架构
ScriptGenerator.ets 是整个项目最核心的模块,约 600 行。其核心设计理念是 模板 + 随机组合 而非调用大模型 API,这使得:
- 生成速度极快(< 50ms,加上 1200ms 的模拟延迟)
- 零 API 成本
- 完全离线可用
- 内容可控(无敏感内容风险)
generateScript(params)
│
├── 生成标题(从 logline 截取前 20 字)
├── 生成角色(generateCharacter × characterCount)
├── 生成场景列表(按幕分组,生成 location/timeOfDay)
└── 生成正文(generateScriptContent)
├── 角色表
├── 逐场景生成
│ ├── 场景标题(内景/外景 + 地点 + 时段)
│ ├── 舞台说明(随机从 ACTION_TEMPLATES 选取)
│ ├── 对话(从 DIALOGUE_TEMPLATES 选取+角色分配)
│ └── 灯暗标记
└── 全剧终 + 版权信息
7.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)];
// 角色定位:第一个是主角,第二个是反派(如果总角色 >2),其余为配角
let role: string;
if (index === 0) { role = 'protagonist'; }
else if (index === 1) { role = total > 2 ? 'antagonist' : 'supporting'; }
else { role = 'supporting'; }
// 角色目标随题材变化
if (genre === 'comedy') { goals = '找到真正的快乐'; }
else if (genre === 'sci-fi') { goals = '拯救人类的未来'; }
// ...
}
命名库设计:
const CHINESE_NAMES_MALE: string[] = [
'张明远', '李浩然', '王思远', '陈志杰', '赵文博',
'林海涛', '周景行', '吴子轩', '刘天宇', '杨云帆',
'孙睿泽', '唐宋雨', '顾北辰', '沈墨言', '陆沉舟',
];
const CHINESE_NAMES_FEMALE: string[] = [
'林语嫣', '苏婉晴', '白若溪', '叶知秋', '沈清月',
'安若素', '花千雪', '柳如烟', '顾清漪', '唐小棠',
'梅若兰', '夏晚晴', '江采薇', '钟离玥', '慕容晴',
];
两个特点:
- 每个名字都带有明确的性别色彩(方便根据性别分配)
- 名字风格多样,兼顾现代感与古风感
- 各 15 个,组合出 [15 × 15 × …] 种角色组合,足够丰富
7.3 场景地点与时段
场景地点按题材分类,确保风格一致性:
const LOCATIONS: Record<string, string[]> = {
'comedy': ['咖啡馆', '学校教室', '写字楼', '菜市场', '地铁站', '婚宴现场'],
'tragedy': ['废弃医院', '老宅', '悬崖边', '墓地', '雨夜街头', '法院'],
'mystery': ['古堡', '地下室', '密室', '警局', '废弃工厂', '图书馆'],
'sci-fi': ['太空站', '实验室', '虚拟世界', '外星殖民地', '时间管理局', 'AI核心舱'],
'romance': ['海边', '樱花树下', '书店', '音乐厅', '天台', '古镇小巷'],
// ...
};
时段固定使用 7 个选项:['清晨', '正午', '午后', '黄昏', '夜晚', '深夜', '黎明']
7.4 对话模板系统
每个题材有 5 组对话模板,每组 2-3 句:
const DIALOGUE_TEMPLATES: Record<string, string[][]> = {
'comedy': [
['你认真的?这主意比早餐还离谱。', '有时候最离谱的就是最棒的!'],
['我不是在笑你,我是在笑这个世界。', '那我们也够惨的,活在一个笑话里。'],
// ...
],
'romance': [
['你就是我的例外。', '但我不喜欢做例外,我想做日常。'],
['世界那么大,我只想待在你身边。', '你什么时候变得这么肉麻了?'],
// ...
],
};
对话中还支持 $SKILL$ 变量占位,运行时替换为随机技能名:
const line1 = diagSet[0].replace(/\$SKILL\$/g, getRandomSkill());
// 例如:"你看我像是有画画的人吗?"
7.5 动作/舞台说明模板
每个题材 6 个动作模板,支持 #NAME# 和 #NAME2# 变量替换:
const ACTION_TEMPLATES: Record<string, string[]> = {
'comedy': [
'#NAME#一不小心滑倒在地,手里的咖啡洒了#NAME2#一身。',
'#NAME#翻了个白眼,深吸一口气,挤出一个虚假的微笑。',
'#NAME#摔门而出,然后两秒后又灰溜溜地回来拿遗忘的手机。',
// ...
],
'horror': [
'#NAME#缓缓转过头——什么都没有。但当#NAME#转回来时,那张脸就在面前。',
'#NAME#看着镜子,镜中的#NAME#露出一个#NAME#没有做的表情。',
// ...
],
};
替换逻辑:
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;
}
7.6 正文生成流水线
function generateScriptContent(
genre: string, style: string,
chars: ScriptCharacter[], scenes: SceneInfo[],
includeStageDirections: boolean,
): string {
const lines: string[] = [];
// 1. 剧本标题
lines.push('《' + getDefaultTitle(genre) + '》');
// 2. 角色表
lines.push('【角色表】');
for (let ci = 0; ci < chars.length; ci++) {
lines.push(c.role + ' ' + c.name + '(' + c.age + ')—— ' + c.personality);
}
// 3. 按场景生成内容
for (let si = 0; si < scenes.length; si++) {
// 幕分隔
if (scene.actNumber !== currentAct) {
lines.push('第 ' + toChineseNumber(currentAct) + ' 幕');
}
// 场景标题
lines.push(scene.sceneNumber + ' ' + scene.title);
// 交替生成 动作 + 对话
for (let step = 0; step < 3 + random(4); step++) {
if (step % 2 === 0 && includeStageDirections) {
// 插入动作描述
}
// 插入对话(从模板随机选取)
}
if (includeStageDirections) lines.push('(灯暗)');
}
// 4. 结尾
lines.push('【全剧终】');
return lines.join('\n');
}
7.7 续写功能
export function continueScript(existingScript: Script, additionalScenes: number): Script {
// 1. 复制已有场景
const newScenes: SceneInfo[] = [...]; // 显式复制
// 2. 生成新场景
for (let i = 0; i < additionalScenes; i++) {
newScenes.push({ /* 场景信息 */ });
}
// 3. 重新生成完整正文(包含旧 + 新场景)
const fullContent = generateScriptContent(
existingScript.genre, existingScript.style,
existingScript.characters, newScenes, true,
);
// 4. 返回更新后的 Script
return { /* ... */ };
}
这里有一个关键设计点:每次续写不是追加文本,而是用全部场景重新生成正文。这样做的原因是避免新旧内容风格割裂,但也意味着续写会改变之前生成的具体台词(保留角色、场景结构,但对话内容会变化)。
7.8 导出功能
支持两种格式:
export function exportScript(script: Script, format: string): string {
if (format === 'txt') {
return script.content;
}
// Markdown 格式
let md = '# 《' + script.title + '》\n\n';
md += '> **题材**:' + getGenreName(script.genre) + '\n';
md += '> **角色数**:' + script.characters.length + '\n';
// ... 结构化 Markdown
return md;
}
配合剪贴板 API 实现分享:
exportText(text: string): void {
try {
const data = pasteboard.createData({ 'text/plain': text });
pasteboard.getSystemPasteboard().setData(data);
this.showToastMsg('已复制到剪贴板');
} catch (_) {
this.showToastMsg('导出失败');
}
}
8. 数据持久化:AppStorage 本地存储方案
8.1 为什么选择 AppStorage
HarmonyOS 提供了多种存储方案:
| 方案 | 场景 | 本项目选择理由 |
|---|---|---|
| AppStorage | 全局可序列化数据 | 剧本数据是纯 JSON,无复杂查询需求 |
| Preferences | KV 持久化 | 盲盒收藏管理器使用 |
| 数据库 (RDB) | 大数据量/复杂查询 | 本项目不需要 |
| 文件系统 | 大文件/BLOB | 不需要 |
8.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 [];
}
版本号策略:存储 key 使用 _v3 后缀。当数据模型变更时,修改版本号即可平滑迁移——旧数据会被 JSON.parse 忽略。
8.3 CRUD 操作
所有写操作都是全量更新模式(read → modify → write):
export function toggleFavorite(scriptId: string): Script[] {
const list = loadScripts();
const result: Script[] = [];
for (let i = 0; i < list.length; i++) {
const s = list[i];
if (s.id === scriptId) {
const updated: Script = {
id: s.id, title: s.title, // ... 显式复制每个字段
isFavorite: !s.isFavorite,
};
result.push(updated);
} else {
result.push(s);
}
}
saveScripts(result);
return result;
}
这种模式在剧本数量不大(通常 < 100 个)时完全够用。如果数据量增大,可以考虑使用索引优化。
8.4 Preferences 在多模块中的应用
CollectionManager 使用了 @ohos.data.preferences API:
import { preferences } from '@kit.ArkData';
async init(context: Context): Promise<void> {
this.pref = await preferences.getPreferences(context, 'sticker_app');
await this.load();
}
private async load(): Promise<void> {
const oVal = await this.pref.get(KEY_COLLECTION, '{}');
const parsed = JSON.parse(oVal as string);
// ...
}
注意 Preferences API 是异步的,而 AppStorage 是同步的。选择依据:
- AppStorage:简单、同步、适合全局状态
- Preferences:异步、支持自定义路径、适合与 Context 绑定的数据
9. 用户交互与体验优化
9.1 Toast 反馈系统
轻量级的 Toast 实现,无需引入第三方库:
@State toastMsg: string = '';
@State 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)
}
9.2 加载状态处理
生成按钮的加载状态通过 @State genLoading 控制:
doGenerate(): void {
this.genLoading = true;
this.genShowResult = false;
setTimeout(() => {
const script = generateScript({ /* params */ });
this.genResult = script;
this.genShowResult = true;
this.genLoading = false;
}, 1200); // 模拟 AI 生成延迟
}
这里的 1200ms 是特意添加的模拟延迟——即使用户端生成只需几毫秒,保留一点等待时间能营造「AI 正在思考」的感觉,提升用户体验。
9.3 确认对话框
删除操作使用 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('已删除');
},
},
});
9.4 搜索与过滤
剧本库支持按关键词搜索和按题材过滤:
getFilteredList(): Script[] {
const all = loadScripts();
let tempList: Script[] = [];
// 收藏筛选 / 题材筛选 / 全部
if (this.listShowFav) { /* 只看收藏 */ }
else if (this.listTab === 'all') { /* 全部 */ }
else { /* 按题材筛选 */ }
// 关键词搜索(大小写不敏感)
if (this.listSearch.trim()) {
const kw = this.listSearch.toLowerCase();
return tempList.filter(s =>
s.title.toLowerCase().includes(kw) ||
s.logline.toLowerCase().includes(kw)
);
}
return tempList;
}
10. 多模块复用设计:盲盒贴纸、动画与 AR 识别
10.1 桌面 3D 盲盒贴纸
盲盒系统是本项目中另一个完整的子模块,展现了 ArkTS 中枚举 + 接口的典型用法:
export enum Rarity {
COMMON = '普通',
RARE = '稀有',
EPIC = '史诗',
LEGENDARY = '传说',
HIDDEN = '隐藏款',
}
export enum Series {
ANIMALS = '动物乐园',
FOOD = '美食世界',
SPACE = '星际探索',
FANTASY = '奇幻森林',
CYBER = '赛博都市',
}
抽卡概率设计:
| 盒子类型 | 普通 | 稀有 | 史诗 | 传说 | 隐藏款 |
|---|---|---|---|---|---|
| 免费盒 | 70% | 25% | 4% | 1% | 0% |
| 白银盒 | 40% | 35% | 18% | 5% | 2% |
| 黄金盒 | 20% | 30% | 28% | 17% | 5% |
private rollRarity(boxType: BoxType): Rarity {
const r = Math.random() * 100;
switch (boxType) {
case BoxType.FREE:
if (r < 70) return Rarity.COMMON;
if (r < 95) return Rarity.RARE;
if (r < 99) return Rarity.EPIC;
return Rarity.LEGENDARY;
// ...
}
}
收藏管理器设计为单例模式:
export class CollectionManager {
private static instance: CollectionManager | null = null;
static getInstance(): CollectionManager {
if (mgrInstance === null) {
mgrInstance = new CollectionManager();
}
return mgrInstance as CollectionManager;
}
}
10.2 照片变定格动画
播放控制组件的设计体现了回调注入模式:
@Component
export struct PlaybackControl {
private isPlaying: boolean = false;
private onPlay: () => void = () => {};
private onPause: () => void = () => {};
private onStep: (dir: number) => void = () => {};
build() {
Row() {
Button() { Text('⏮') }.onClick(() => { this.onStep(-1); })
Button() { Text(this.isPlaying ? '⏸' : '▶') }
.onClick(() => {
this.isPlaying = !this.isPlaying;
if (this.isPlaying) this.onPlay();
else this.onPause();
})
Button() { Text('⏭') }.onClick(() => { this.onStep(1); })
}
}
}
使用方通过 setter 方法注入回调:
playbackCtrl.setOnPlay(() => { /* 播放逻辑 */ });
playbackCtrl.setOnPause(() => { /* 暂停逻辑 */ });
10.3 AR 饮食识别面板
ScanResultPanel 使用了 @Link 双向绑定:
@Component
export struct ScanResultPanel {
@Link result: RecognitionResult | null;
@Link showDetail: boolean;
}
@Link 使子组件可以修改父组件的状态,适合「面板关闭→更新父页面」的场景。
10.4 游戏高光时刻剪辑
TimelineBar 和 VideoPreview 共享 ClipProject 数据:
@Component
export struct TimelineBar {
@Link project: ClipProject | null;
}
@Component
export struct VideoPreview {
@Link project: ClipProject | null;
}
时间轴裁剪的核心逻辑:
moveStart(delta: number): void {
const newStart = p.startTime + delta;
if (newStart >= 0 && newStart < p.endTime - 500) {
this.project = { /* 复制并更新 startTime */ };
}
}
11. 性能优化与 ArkTS 合规实践
11.1 ArkTS 合规检查清单
| 规则 | 违反后果 | 本项目做法 |
|---|---|---|
禁止 any/unknown |
编译报错 | 所有类型显式标注 |
| 禁止解构赋值 | 编译报错 | 逐个字段赋值 |
| 禁止展开运算符 | 编译报错 | 显式对象复制 |
@Builder 内禁止变量声明 |
行为未定义 | 逻辑移到 struct 方法 |
禁止 typeof |
编译报错 | 使用类型守卫 |
11.2 响应式性能优化
1. 状态粒度控制
将页面切分为多个 @Builder,使状态变化时只重绘受影响的部分:
@Builder
GenAdvancedSettings() { /* 只有展开/折叠时重新渲染 */ }
@Builder
GenResultCard(script: Script) { /* 只有生成完成时重新渲染 */ }
2. 避免不必要的重建
ForEach 的 key 使用唯一 ID:
ForEach(GENRE_LIST, (g: GenreItem) => {
GridItem() { /* ... */ }
})
// ForEach 默认使用对象引用作为 key,对于常量列表没问题
3. 动画性能
BlindBoxItem 使用 animation 实现摇晃效果,仅改变一个 rotate 属性:
.rotate({ angle: this.shaking ? 5 : 0 })
.animation({ duration: 200, curve: Curve.EaseInOut, iterations: this.shaking ? -1 : 0 })
11.3 内存优化
- 所有 ID 使用
string而非number(避免精度问题) - 历史记录限制 100 条:
if (this.history.length > 100) { this.history.shift(); } - 搜索使用低开销的
includes而非正则 - 角色表使用
Array而非Map(数据量小,遍历成本可忽略)
11.4 安全与健壮性
防御性编程:
export function getScript(scriptId: string): Script | null {
const list = loadScripts();
for (let i = 0; i < list.length; i++) {
if (list[i].id === scriptId) return list[i];
}
return null; // 不存在时返回 null,而非抛异常
}
异常捕获:
try {
const data = pasteboard.createData({ 'text/plain': text });
pasteboard.getSystemPasteboard().setData(data);
} catch (_) {
this.showToastMsg('导出失败');
}
12. 测试策略与质量保障
12.1 测试文件结构
entry/src/
├── main/ # 生产代码
├── mock/
│ └── mock-config.json5 # Mock 配置
├── ohosTest/
│ ├── module.json5
│ └── ets/test/ # 鸿蒙原生测试
├── test/
│ ├── List.test.ets # 列表测试
│ └── LocalUnit.test.ets # 本地单元测试
12.2 测试重点
AI 引擎测试(核心):
- generateScript: 验证输出结构完整性
- 角色数量 = characterCount
- 场景数量 = sceneCount
- 幕数 = actCount
- content 包含角色表和「全剧终」
- wordCount = content.length
- continueScript: 验证续写不丢失原数据
- scenes 数量 = 原场景数 + additionalScenes
- createdAt 不变
- updatedAt 更新
- exportScript: 验证格式正确性
- txt 格式直接返回 content
- markdown 格式包含 # 标题和 --- 分割线
存储层测试:
- createScript → loadScripts 一致性
- toggleFavorite 切换正确
- deleteScript 不删除其他剧本
- getStats 统计数据准确
12.3 Mock 策略
使用 @ohos/hamock 进行接口模拟:
// 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,
// "parallel": true,
},
logging: {
// "level": "info"
},
}
13.2 模块配置
// entry/build-profile.json5
{
"apiType": "stageMode", // Stage 模型
"buildOption": {
"arkOptions": {
"strictMode": "strict" // 严格模式
}
}
}
13.3 应用签名
// AppScope/app.json5
{
"app": {
"bundleName": "com.atomgit.aiplay",
"vendor": "AtomGit",
"versionCode": 1000000,
"versionName": "1.0.0",
"buildVersion": "1",
"icon": "$media:layered_image",
"label": "$string:app_name"
}
}
13.4 混淆规则
# obfuscation-rules.txt
-keep class com.atomgit.aiplay.** { *; }
14. 开发心得与踩坑记录
14.1 常见问题与解决方案
Q1: ArkTS 编译报错"Unsupported destructuring"
原因:ArkTS 不支持解构赋值语法。
解决:逐个字段赋值。
// ❌ 错误写法
const { id, title } = script;
// ✅ 正确写法
const id = script.id;
const title = script.title;
Q2: @Builder 内使用变量不生效
原因:@Builder 不允许声明变量,但可以读取 struct 的属性和方法。
解决:在 @Builder 外部计算好值,通过参数传入。
// ✅ 正确写法
@Builder
ScriptCard(script: Script) {
Text(genreIcon(script.genre)) // 调用 struct 方法
}
Q3: AppStorage 数据丢失
原因:存储 key 在不同版本之间不兼容。
解决:使用版本化 key(如 ai_script_list_v3),模型变更时递增版本号。
Q4: setState 不触发 UI 更新
原因:直接修改 @State 数组的元素不会触发响应式更新。
解决:创建新数组替换。
// ❌ 不触发更新
this.scripts[0].title = '新标题';
// ✅ 触发更新
const newList = [...]; // 显式创建副本
newList[0] = { ...this.scripts[0], title: '新标题' };
this.scripts = newList;
14.2 设计决策日志
| 决策 | 选项 | 选择 | 理由 |
|---|---|---|---|
| 页面路由 | Navigation vs 单组件 | 单组件 | 状态共享简单,文件数少 |
| AI 引擎 | LLM API vs 模板引擎 | 模板引擎 | 零成本、离线可用、可控 |
| 存储方案 | RDB vs AppStorage | AppStorage | 数据结构简单,查询需求低 |
| ID 生成 | UUID vs 时间戳+随机 | 时间戳+随机 | 无外部依赖,足够唯一 |
| 状态管理 | 全局 Store vs @State | @State | 应用规模小,无需引入 Redux 模式 |
| 导出格式 | PDF vs TXT vs MD | TXT + MD | 实现简单,通用性高 |
14.3 经验总结
-
类型先行:在 ArkTS 中,好的类型设计能避免 80% 的编译错误。每个接口、函数都应该有明确的类型签名。
-
@Builder 是质量倍增器:将 UI 切分为
@Builder不仅提高复用性,还能让每个@Builder的职责清晰,降低调试难度。 -
模拟延迟提升体验:即使是本地生成,添加 1-2 秒的模拟延迟比瞬间输出更符合用户对「AI 生成」的心理预期。
-
显示复制 vs 解构:刚开始写显示复制很痛苦,但它迫使开发者明确每个字段的值,避免了因解构导致的隐式引用问题。
-
防御性返回 null:所有 get/query 函数都返回
null而非抛出异常,上层调用者自行决定如何处理空值。
15. 总结与展望
15.1 项目成果
通过本文的完整介绍,我们完成了一个功能丰富的 AI 辅助剧本创作 App,涵盖:
- AI 生成引擎:基于模板系统的剧本生成器,支持 10 种题材 × 5 种风格
- 完善的管理系统:剧本 CRUD、收藏、搜索、角色管理
- 多种导出方式:剪贴板复制、Markdown 导出
- 多模块整合:盲盒贴纸、定格动画、AR 识别、视频剪辑
- 全流程开发:从类型设计到 UI 实现,从本地存储到测试部署
15.2 可扩展方向
- 接入真实 LLM API:将模板引擎替换为调用大模型 API,实现真正的 AI 生成
- 云端同步:通过
@kit.NetworkKit实现多设备剧本同步 - 多人协作:基于
@kit.DistributedKit的分布式协作编辑 - 语音输入:集成语音转文字,口述故事梗概
- 剧本排版:引入富文本渲染,支持字体、颜色、格式调整
- 社区分享:搭建剧本广场,用户可分享和下载公开剧本
15.3 结语
构建 AI 辅助的创意工具,技术实现只是第一步。真正重要的是理解创作者的 workflow,在合适的位置融入 AI 能力,而不是为了 AI 而 AI。
本项目中的「AI」并非真正的深度学习模型,而是一个精心设计的模板+随机引擎——但这恰恰说明了:AI 辅助体验的核心不在于模型有多强,而在于产品设计能否降低用户的创作门槛,激发他们的表达欲望。
当用户选择题材、调整参数、点击生成,看到属于自己的剧本缓缓展开时,那种「创作完成」的成就感,无论是来自大模型还是模板引擎,都不会有区别。
附录
A. 关键文件索引
| 文件路径 | 行数 | 职责 |
|---|---|---|
ets/pages/111.ets |
~960 | 主页面(6 个子页面) |
ets/model/ScriptGenerator.ets |
~600 | AI 剧本生成引擎 |
ets/model/ScriptStorage.ets |
~190 | 存储 CRUD |
ets/model/ScriptTypes.ets |
~210 | 类型定义 + 常量 + 工具函数 |
ets/model/CollectionManager.ets |
~225 | 盲盒收藏管理器 |
ets/model/StickerData.ets |
~96 | 贴纸数据模型 |
B. 依赖清单
@ohos/hamock@1.0.0 # 测试 Mock
@ohos/hypium@1.0.25 # 测试框架
@kit.BasicServicesKit # 基础服务(pasteboard 等)
@kit.ArkData # 数据存储(preferences)
C. 参考资源
更多推荐



所有评论(0)