ArkTS 极简全能创作软件实战 —— 从零构建绘图、文案、修图三合一 App
ArkTS 极简全能创作软件实战 —— 从零构建绘图、文案、修图三合一 App

目录
- HarmonyOS NEXT 与 ArkTS 概述
- 项目结构设计
- EntryAbility:应用入口与生命周期
- Tabs 底部导航架构
- Flex 响应式卡片流布局(FlexCardLayout)
- 模块一:绘图页面 —— Canvas 手指绘画
- 模块二:写文案页面 —— 文本编辑与剪贴板
- 模块三:修照片页面 —— Canvas 像素滤镜引擎
- 通用组件与类型定义
- @State 响应式数据流深度解析
- Hvigor 构建与调试
- ArkTS 最佳实践与常见陷阱
- 总结与扩展方向
1. HarmonyOS NEXT 与 ArkTS 概述
1.1 什么是 HarmonyOS NEXT
HarmonyOS NEXT 是华为从底层自研的操作系统,去掉了 AOSP 代码,完全基于鸿蒙内核。这意味着它不再兼容 Android 应用,所有应用必须使用鸿蒙原生开发方式。API 24 是 HarmonyOS NEXT 的一个里程碑版本,带来了更完善的 ArkUI 组件能力和更稳定的 ArkTS 编译器。
1.2 ArkTS 语言特性
ArkTS 是鸿蒙原生的声明式 UI 开发语言,基于 TypeScript 语法扩展。它与 SwiftUI、Jetpack Compose 属于同一代声明式 UI 范式。核心特性包括:
- @Entry @Component 装饰器:标记页面入口和组件
- @State @Link @Prop 装饰器:响应式状态管理
- @Builder 装饰器:轻量级 UI 构建函数
- 链式 API 调用:
.width().height().backgroundColor()等 - ForEach 循环渲染:列表数据驱动 UI
- 内置 Canvas 2D API:与 Web Canvas 高度相似
1.3 工程结构
一个标准的 HarmonyOS NEXT 工程结构如下:
project-root/
├── entry/
│ └── src/
│ └── main/
│ ├── ets/
│ │ ├── entryability/ # Ability 生命周期
│ │ │ └── EntryAbility.ets
│ │ └── pages/ # 页面文件
│ │ ├── Index.ets
│ │ ├── FlexCardLayout.ets
│ │ └── CreativeApp.ets
│ ├── resources/ # 资源文件
│ └── module.json5 # 模块配置
├── build-profile.json5 # 构建配置
├── hvigorfile.ts # 构建脚本
└── oh-package.json5 # 包管理
2. 项目结构设计
2.1 本文涉及的页面
本文围绕两个页面展开:
| 文件 | 功能 | 行数 |
|---|---|---|
CreativeApp.ets |
极简全能创作软件(绘图+文案+修图) | 910 行 |
FlexCardLayout.ets |
Flex 响应式卡片流布局演示 | 583 行 |
EntryAbility.ets |
应用入口,加载 CreativeApp 页面 | 48 行 |
2.2 设计思路
我们的目标是构建一个「新手友好、一应俱全」的创作工具。三个核心功能互不依赖,适合用 Tabs 底部导航组织。每个功能独立为一个 @Component,通过主入口的 @State 控制 Tab 切换状态。
3. EntryAbility:应用入口与生命周期
3.1 UIAbility 基础
HarmonyOS 的 Ability 相当于 Android 的 Activity 或 iOS 的 UIViewController。EntryAbility 是应用的启动入口,继承自 UIAbility。
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
const DOMAIN = 0x0000;
export default class EntryAbility extends UIAbility {
// Ability 创建时调用
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
try {
// 设置颜色模式:跟随系统
this.context.getApplicationContext()
.setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
} catch (err) {
hilog.error(DOMAIN, 'testTag',
'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
}
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
}
}
3.2 加载页面
最关键的方法是 onWindowStageCreate,在这里通过 loadContent 指定首页的页面路径:
onWindowStageCreate(windowStage: window.WindowStage): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
windowStage.loadContent('pages/CreativeApp', (err) => {
if (err.code) {
hilog.error(DOMAIN, 'testTag',
'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
return;
}
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
});
}
loadContent 的第一个参数是页面路径,对应 pages/ 目录下的 .ets 文件(不含扩展名)。路径格式为 'pages/页面名',必须与 @Entry 装饰的结构体名称一致。
3.3 生命周期方法
| 方法 | 触发时机 | 用途 |
|---|---|---|
onCreate |
Ability 首次创建 | 初始化数据、配置 |
onDestroy |
Ability 销毁 | 释放资源 |
onWindowStageCreate |
窗口创建 | 加载 UI 页面 |
onWindowStageDestroy |
窗口销毁 | 清理窗口资源 |
onForeground |
进入前台 | 恢复 UI 状态 |
onBackground |
进入后台 | 保存数据快照 |
4. Tabs 底部导航架构
4.1 主入口结构
CreativeApp 结构体使用 @Entry 和 @Component 装饰,表示它是一个页面入口。内部通过 Tabs 组件实现底部导航栏,配合 @State currentIndex 追踪当前选中的 Tab。
@Entry
@Component
struct CreativeApp {
@State private currentIndex: number = 0;
build() {
Column({ space: 0 }) {
// ---- 顶栏 ----
Row() {
Text('✨ 极简创作')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A2E')
Blank()
Text('新手友好 · 一应俱全')
.fontSize(11)
.fontColor('#AAA')
}
.width('100%')
.padding({ left: 20, right: 20, top: 12, bottom: 8 })
.backgroundColor('#FFFFFF')
// ---- Tabs 内容区 ----
Tabs({
barPosition: BarPosition.End, // 标签栏在底部
index: this.currentIndex,
onChange: (idx: number) => { this.currentIndex = idx; }
}) {
TabContent() { DrawPage() }
.tabBar(this.TabBarItem('🎨', '绘图', 0))
TabContent() { WritePage() }
.tabBar(this.TabBarItem('✍️', '写文案', 1))
TabContent() { PhotoEditPage() }
.tabBar(this.TabBarItem('📷', '修照片', 2))
}
.width('100%')
.height('100%')
.backgroundColor('#F5F6FA')
}
}
}
4.2 TabBarItem 构建器
@Builder 是 ArkTS 中用于构建 UI 片段的轻量级函数。与 @Component 不同,@Builder 没有独立的状态管理,适合封装纯展示性 UI。
@Builder
TabBarItem(icon: string, label: string, index: number) {
Column({ space: 2 }) {
Text(icon).fontSize(22)
Text(label)
.fontSize(11)
.fontColor(this.currentIndex === index ? '#6C63FF' : '#999')
}
.width('100%')
.padding({ top: 6, bottom: 4 })
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
}
关键设计细节:
- 选中态高亮:通过
this.currentIndex === index判断当前 Tab,高亮色为#6C63FF(紫色),非选中为#999(灰色) barPosition: BarPosition.End:将 Tab 栏固定在底部,符合移动端常见交互模式onChange回调:Tab 切换时更新currentIndex,从而触发 UI 刷新
4.3 Tabs 组件注意事项
| 属性 | 说明 |
|---|---|
barPosition |
BarPosition.Start(顶部)/ BarPosition.End(底部) |
index |
当前选中的 Tab 索引(受控模式) |
onChange |
Tab 切换回调,参数为选中索引 |
ArkTS 中 Tabs 的受控模式需要同时设置 index 和 onChange 才能正常工作。只设置 index 而不处理 onChange 会导致点击 Tab 后切换失败。
5. Flex 响应式卡片流布局(FlexCardLayout)
5.1 布局原理
在移动端开发中,商品卡片、图片墙的自适应排列是最常见的 UI 需求之一。传统方式需要监听屏幕宽度、计算列数、编写断点。而在 ArkTS 中,Flex + wrap + layoutWeight 的组合可以零代码实现完全自适应的响应式卡片流。
核心思路:
Flex({ wrap: FlexWrap.Wrap }) // 超出换行
→ 每张卡片 .layoutWeight(1) // 行内等分
→ .constraintSize({ minWidth, maxWidth }) // 边界保护
5.2 数据模型定义
// 商品数据
class Commodity {
id: number = 0;
name: string = '';
price: number = 0;
imageColor: string = '';
icon: string = '';
constructor(id: number, name: string, price: number, color: string, icon: string) {
this.id = id;
this.name = name;
this.price = price;
this.imageColor = color;
this.icon = icon;
}
}
// 图片墙数据
class WallImage {
id: number = 0;
label: string = '';
gradientStart: string = '';
gradientEnd: string = '';
icon: string = '';
}
5.3 核心 Flex 容器
Flex({
wrap: FlexWrap.Wrap, // 【核心】自动换行
direction: FlexDirection.Row, // 水平排列
justifyContent: FlexAlign.SpaceBetween,
alignItems: ItemAlign.Start,
}) {
ForEach(this.commodities, (item: Commodity, index: number) => {
this.CommodityCard(item, index)
}, (item: Commodity) => item.id.toString())
}
.width('100%')
5.4 layoutWeight 响应式等分
每张卡片设置 .layoutWeight(1),表示同一行内的所有卡片权重相同,layoutWeight 会将行内剩余空间按权重比例分配给每个卡片。
@Builder
CommodityCard(item: Commodity, index: number) {
Column({ space: 6 }) {
// 模拟商品图
Row() {
Text(item.icon).fontSize(32)
}
.width('100%')
.aspectRatio(1.0)
.backgroundColor(item.imageColor)
.borderRadius(12)
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Center)
// 商品名称
Text(item.name)
.fontSize(13)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
// 价格
Text(`¥${item.price}`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FF6B81')
.width('100%')
}
.layoutWeight(1) // ← 行内等分剩余空间
.constraintSize({ minWidth: 100, maxWidth: 200 }) // ← 防变形
.margin(6)
.backgroundColor(Color.White)
.borderRadius(14)
.shadow({ radius: 6, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 3 })
}
5.5 自适应效果对照
| 设备宽度 | 卡片最小宽度 | 每行列数 | 排列效果 |
|---|---|---|---|
| 360dp(手机竖屏) | 100dp | 3 列 | 三列紧凑排列 |
| 600dp(大屏手机) | 100dp | 4~5 列 | 空间充裕 |
| 800dp(折叠屏展开) | 100dp | 5~6 列 | 充分利用宽屏 |
完全不需编写 @MediaQuery 断点,Flex 自身完成全部适配。
5.6 交互:增删卡片
// 新增商品
private addCommodity(): void {
const names = ['智能台灯', '电动牙刷', '加湿器', '充电宝', '数据线', '手机壳', '移动硬盘', '摄像头'];
const colors = ['#FF6348', '#2ED573', '#5352ED', '#FFA502', '#70A1FF', '#FF4757'];
const icons = ['💡', '🪥', '💨', '🔋', '🔌', '📱', '💾', '📹'];
const idx = Math.floor(Math.random() * names.length);
const price = Math.floor(Math.random() * 3000) + 49;
this.commodities.push(
new Commodity(this.nextCommodityId, names[idx], price, colors[idx % colors.length], icons[idx])
);
this.nextCommodityId++;
promptAction.showToast({ message: `✅ 已添加「${names[idx]}」`, duration: 1200 });
}
// 删除最后一件
private removeLastCommodity(): void {
if (this.commodities.length === 0) {
promptAction.showToast({ message: '⚠️ 已经没有商品了', duration: 1200 });
return;
}
const removed = this.commodities.pop()!;
promptAction.showToast({ message: `🗑️ 已删除「${removed.name}」`, duration: 1200 });
}
@State 数组的响应式行为
在 ArkTS 中,@State 监听的是数组引用变化。直接调用 push() / pop() 会触发数组的写操作,ArkTS 编译器会自动将其转换为响应式更新。这一点与 React 的 useState 不同——React 需要不可变数据([...arr, newItem]),而 ArkTS 可以安全地调用数组的变异方法。
6. 模块一:绘图 Page —— Canvas 手指绘画
6.1 技术方案选择
绘图功能是 Canvas 2D API 最经典的应用场景。ArkTS 提供的 CanvasRenderingContext2D 与 Web Canvas API 高度兼容,支持 beginPath、moveTo、lineTo、stroke、arc、fillRect 等全部核心绘图方法。
6.2 状态定义
@Component
struct DrawPage {
// Canvas 渲染上下文
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
// @State 驱动 UI 的状态
@State private currentColor: string = '#6C63FF';
@State private brushSize: number = 4;
@State private isDrawing: boolean = false;
// 笔画历史(非 UI 状态,不触发刷新)
private strokes: DrawStroke[] = []; // 已完成笔画
private currentStroke: DrawPoint[] = []; // 当前正在画的笔画
这里的关键设计思路是分层管理状态:
- @State 变量(
currentColor,brushSize):UI 直接依赖这些值——颜色盘选中态、滑条数值、工具栏文字都基于它们渲染。修改它们会触发build()重新执行。 - 私有实例变量(
strokes,currentStroke):这些是内部状态,UI 不直接展示笔画列表。修改它们不触发 UI 刷新,只在需要重绘时通过context.stroke()手动操作 Canvas。
6.3 Canvas 声明
Canvas(this.context)
.width('100%')
.height(420)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.shadow({ radius: 8, color: 'rgba(0,0,0,0.06)', offsetY: 4 })
.onReady(() => {
this.drawGrid();
})
.onTouch((event: TouchEvent) => {
this.handleCanvasTouch(event);
})
Canvas 组件通过 CanvasRenderingContext2D 实例连接,onReady 回调保证 Canvas 的 width 和 height 属性已就绪,是执行初始化的最佳时机。onTouch 是触摸事件入口,处理 Down / Move / Up 三种触摸类型。
6.4 触摸事件处理
private handleCanvasTouch(event: TouchEvent): void {
// 获取 Canvas 在屏幕上的位置,将触摸坐标转换为画布坐标
const rect = this.context.canvas.getBoundingRect();
const x = event.touches[0].x - (rect.left ?? 0);
const y = event.touches[0].y - (rect.top ?? 0);
switch (event.type) {
case TouchType.Down:
this.isDrawing = true;
this.currentStroke = [{ x, y }]; // 记录新笔画起点
this.context.beginPath();
this.context.moveTo(x, y);
break;
case TouchType.Move:
if (!this.isDrawing) return;
this.context.lineTo(x, y);
this.context.strokeStyle = this.currentColor;
this.context.lineWidth = this.brushSize;
this.context.lineCap = LineCap.Round; // 圆头端点
this.context.lineJoin = LineJoin.Round; // 圆角连接
this.context.stroke();
this.currentStroke.push({ x, y }); // 记录路径点
break;
case TouchType.Up:
this.isDrawing = false;
if (this.currentStroke.length > 0) {
this.strokes.push([...this.currentStroke]); // 保存整笔笔画
this.currentStroke = [];
}
break;
}
}
坐标转换详解
Canvas 可能位于屏幕的任何位置(受父容器 padding、margin 等影响)。getBoundingRect() 返回 Canvas 在屏幕坐标系中的位置和尺寸。触摸事件的 touches[0].x 是屏幕坐标,需要减去 rect.left 和 rect.top 才能得到 Canvas 内部的绘制坐标。
// 不正确的做法(直接使用 touch 坐标):
const x = event.touches[0].x; // ❌ 忽略了 Canvas 的偏移
// 正确的做法:
const rect = this.context.canvas.getBoundingRect();
const x = event.touches[0].x - rect.left; // ✅ 转换为画布坐标
6.5 撤销功能实现
撤销的核心思路是「笔画历史栈」。strokes 数组存储每一笔完整路径,撤销时 pop() 弹出最后一笔,然后清空画布从零重绘。
private undo(): void {
if (this.strokes.length === 0) {
promptAction.showToast({ message: '⚠️ 没有可撤销的笔画', duration: 1200 });
return;
}
this.strokes.pop(); // 移除最后一笔
this.redrawAll(); // 清空并重绘所有剩余笔画
promptAction.showToast({ message: '↩ 已撤销上一步', duration: 1000 });
}
private redrawAll(): void {
const ctx = this.context;
const w = ctx.canvas.width;
const h = ctx.canvas.height;
ctx.clearRect(0, 0, w, h); // 清空画布
this.drawGrid(); // 绘制背景网格
for (const stroke of this.strokes) {
if (stroke.length < 2) continue;
ctx.beginPath();
ctx.moveTo(stroke[0].x, stroke[0].y);
for (let i = 1; i < stroke.length; i++) {
ctx.lineTo(stroke[i].x, stroke[i].y);
}
ctx.strokeStyle = this.currentColor;
ctx.lineWidth = this.brushSize;
ctx.lineCap = LineCap.Round;
ctx.lineJoin = LineJoin.Round;
ctx.stroke();
}
}
6.6 保存画作为图片
CanvasRenderingContext2D 提供了 getPixelMap() 方法,可以将 Canvas 的当前帧转换为 PixelMap(像素图),再通过 image.ImagePacker 编码为 PNG 格式的 ArrayBuffer。
import { image } from '@kit.ImageKit';
private saveDrawing(): void {
const ctx = this.context;
const pixelMap = ctx.getPixelMap(0, 0, ctx.canvas.width, ctx.canvas.height);
if (!pixelMap) {
promptAction.showToast({ message: '⚠️ 保存失败,请重试', duration: 1500 });
return;
}
const packer: image.ImagePacker = image.createImagePacker();
const packOpts: image.PackingOption = { format: 'image/png', quality: 100 };
const arrayBuffer: ArrayBuffer = packer.packing(pixelMap, packOpts);
packer.release();
const sizeKB: number = Math.round(arrayBuffer.byteLength / 1024);
promptAction.showToast({
message: `✅ 画作已保存!(${sizeKB}KB)`,
duration: 2000,
});
}
注意:本示例以 Toast 提示模拟保存成功。在完整应用中,可以通过
@ohos.file.fs或@ohos.multimedia.mediaLibrary将ArrayBuffer写入设备媒体库。
6.7 颜色盘与笔刷
颜色盘使用 Flex + wrap 实现流式排列,12 种预设颜色可点击切换:
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
ForEach(this.presetColors, (color: string) => {
Row() {
if (color === this.currentColor) {
Text('✓')
.fontSize(14)
.fontColor(color === '#F9F9F9' ? '#333' : '#FFF')
.fontWeight(FontWeight.Bold)
}
}
.width(32).height(32)
.backgroundColor(color)
.borderRadius(16)
.border({
width: color === this.currentColor ? 2.5 : 1,
color: color === this.currentColor ? '#6C63FF' : '#E8E8E8',
})
.margin(4)
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Center)
.onClick(() => { this.currentColor = color; })
})
}
笔刷大小通过 Slider 滑条调节,范围 1~20px:
Slider({
value: this.brushSize,
min: 1,
max: 20,
step: 1,
style: SliderStyle.OutSet,
})
.width('70%')
.trackThickness(4)
.blockColor('#6C63FF')
.trackColor('#E0E0FF')
.selectedColor('#6C63FF')
.onChange((val: number) => { this.brushSize = val; })
Slider 关键属性说明:
value:当前值(双向绑定)min/max:取值范围step:步进单位style:SliderStyle.OutSet滑块凸起风格,SliderStyle.InSet滑块内嵌风格
7. 模块二:写文案 Page —— 文本编辑与剪贴板
7.1 状态与 UI 布局
@State private content: string = ''; // 文案内容
@State private fontSize: number = 16; // 字号
@State private templateIndex: number = -1; // 当前选中的模板索引
文案编辑区使用 TextArea 组件,它是 ArkUI 中支持多行文本输入的组件,与 TextInput(单行)不同,适合段落式的文案创作场景。
TextArea({
text: this.content,
placeholder: '在这里输入文案……\n\n💡 试试点击下方的「快捷模板」一键填充',
})
.width('100%')
.height(240)
.fontSize(this.fontSize)
.fontColor('#1A1A2E')
.backgroundColor(Color.White)
.borderRadius(16)
.padding(14)
.placeholderFont({ size: 13, color: '#CCC' })
.onChange((val: string) => { this.content = val; })
.shadow({ radius: 6, color: 'rgba(0,0,0,0.04)', offsetY: 3 })
7.2 字号滑动调节
字号调节通过 Slider 组件实现,范围 12~28px。A⁻ 和 A⁺ 标签直观显示缩小/放大功能:
Row({ space: 10 }) {
Text('A⁻').fontSize(13).fontColor('#999')
Slider({
value: this.fontSize, min: 12, max: 28, step: 1,
style: SliderStyle.OutSet,
})
.width('55%')
.trackThickness(3)
.blockColor('#6C63FF')
.trackColor('#E0E0FF')
.selectedColor('#6C63FF')
.onChange((v: number) => { this.fontSize = v; })
Text('A⁺').fontSize(16).fontColor('#999')
Blank()
Button('📋 复制')
.fontSize(13)
.height(34)
.backgroundColor('#6C63FF')
.onClick(() => this.copyToClipboard())
}
7.3 系统剪贴板操作
ArkTS 通过 @kit.BasicServicesKit 模块提供 pasteboard 剪贴板服务。写入流程:
import { pasteboard } from '@kit.BasicServicesKit';
private copyToClipboard(): void {
if (!this.content.trim()) {
promptAction.showToast({ message: '⚠️ 还没有内容可以复制', duration: 1200 });
return;
}
try {
// 1. 创建纯文本剪贴板数据
const data: pasteboard.PasteData =
pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, this.content);
// 2. 获取系统剪贴板实例并写入
pasteboard.getSystemPasteboard().setPasteData(data);
promptAction.showToast({ message: '✅ 文案已复制到剪贴板', duration: 1500 });
} catch (err) {
promptAction.showToast({ message: '⚠️ 复制失败,请手动复制', duration: 1500 });
}
}
pasteboard API 详解
| API | 说明 |
|---|---|
pasteboard.createData(mimeType, text) |
创建一条剪贴板数据 |
pasteboard.getSystemPasteboard() |
获取系统剪贴板单例 |
pasteboard.setPasteData(data) |
将数据写入剪贴板 |
pasteboard.MIMETYPE_TEXT_PLAIN |
纯文本 MIME 类型常量 |
7.4 快捷模板
6 种预设文案模板覆盖多种场景,点击即填充到编辑区:
private readonly templates: string[] = [
'✨ 生活的诗意,藏在每一个认真的日子里。\n—— 极简创作 · 每日一句',
'🎯 新品上市!限时特惠,错过等一年~\n点击链接立即抢购 👉',
'📸 镜头定格的瞬间,是时光写给未来的情书。',
'💪 自律不是束缚,而是通往自由的钥匙。\n今天也要加油鸭! 🦆',
'🌅 早安!新的一天,新的可能。\n愿你心中有光,脚下有路。',
'🎉 感恩遇见,感谢相伴。\n这份心意,只给最特别的你。',
];
模板采用胶囊式按钮展示,选中态高亮为紫色:
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
ForEach(this.templates, (tpl: string, idx?: number) => {
Row() {
Text(tpl.substring(0, 18) + (tpl.length > 18 ? '…' : ''))
.fontSize(12)
.fontColor('#555')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor(idx === this.templateIndex ? '#6C63FF' : '#F0F0FF')
.borderRadius(20)
.margin(4)
.onClick(() => {
this.templateIndex = idx ?? 0;
this.content = tpl;
})
})
}
8. 模块三:修照片 Page —— Canvas 像素滤镜引擎
8.1 技术原理
修照片功能的核心是 Canvas 像素级操作。整体流程如下:
1. Canvas 绘制示例图片
2. 调用 getImageData() 获取像素数组 [R,G,B,A, R,G,B,A, ...]
3. 遍历像素数组,对每个像素的 R/G/B 应用滤镜算法
4. 调用 putImageData() 将处理后的像素写回 Canvas
8.2 状态定义
@Component
struct PhotoEditPage {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private isReady: boolean = false;
@State private hasImage: boolean = false;
@State private currentFilter: string = '原图';
@State private brightness: number = 100;
@State private contrast: number = 100;
@State private sampleImageLoaded: boolean = false;
private originalData: ImageData | null = null; // 原始像素数据缓存
private canvasWidth: number = 0;
private canvasHeight: number = 0;
private readonly filters: FilterDef[] = [
{ name: '原图', icon: '🟦' },
{ name: '黑白', icon: '⬛' },
{ name: '怀旧', icon: '🟫' },
{ name: '反转', icon: '🔄' },
{ name: '冷色', icon: '🔵' },
{ name: '暖色', icon: '🟠' },
];
}
8.3 加载示例图
本示例不依赖外部图片文件,而是用 Canvas 绘制作一幅包含渐变天空、太阳、山峦、树木的彩色风景图:
private loadSampleImage(): void {
if (!this.isReady) return;
const ctx = this.context;
const w = ctx.canvas.width;
const h = ctx.canvas.height;
this.canvasWidth = w;
this.canvasHeight = h;
// 1. 创建渐变背景
const grad = ctx.createLinearGradient(0, 0, w, h);
grad.addColorStop(0, '#FF6B6B');
grad.addColorStop(0.3, '#FFD93D');
grad.addColorStop(0.6, '#6BCB77');
grad.addColorStop(1, '#4D96FF');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
// 2. 绘制太阳(带阴影辉光)
ctx.beginPath();
ctx.arc(w * 0.3, h * 0.35, w * 0.12, 0, Math.PI * 2);
ctx.fillStyle = '#FFD93D';
ctx.fill();
ctx.shadowColor = 'rgba(255,217,61,0.4)';
ctx.shadowBlur = 30;
ctx.fill();
ctx.shadowBlur = 0;
// 3. 绘制山峦
ctx.beginPath();
ctx.moveTo(0, h * 0.75);
ctx.lineTo(w * 0.25, h * 0.45);
ctx.lineTo(w * 0.5, h * 0.7);
ctx.lineTo(w * 0.75, h * 0.4);
ctx.lineTo(w, h * 0.65);
ctx.lineTo(w, h);
ctx.lineTo(0, h);
ctx.closePath();
ctx.fillStyle = '#2D3436';
ctx.globalAlpha = 0.3;
ctx.fill();
ctx.globalAlpha = 1;
// 4. 绘制树木
for (let i = 0; i < 5; i++) {
const tx = w * (0.1 + i * 0.2);
const ty = h * (0.5 + Math.sin(i * 1.5) * 0.08);
ctx.beginPath();
ctx.moveTo(tx, ty);
ctx.lineTo(tx - w * 0.04, ty + h * 0.15);
ctx.lineTo(tx + w * 0.04, ty + h * 0.15);
ctx.closePath();
ctx.fillStyle = '#6BCB77';
ctx.globalAlpha = 0.5;
ctx.fill();
ctx.fillStyle = '#8B4513';
ctx.globalAlpha = 0.4;
ctx.fillRect(tx - 3, ty + h * 0.15, 6, h * 0.05);
}
ctx.globalAlpha = 1;
// 5. 保存原始像素
this.originalData = ctx.getImageData(0, 0, w, h);
this.hasImage = true;
this.sampleImageLoaded = true;
}
8.4 像素滤镜算法
这是整个应用技术含量最高的部分。每个滤镜通过数学公式修改像素的 R/G/B 值:
private applyCurrentFilter(): void {
if (!this.originalData) return;
const srcData = new Uint8ClampedArray(this.originalData.data);
const imgData = new ImageData(srcData, this.canvasWidth, this.canvasHeight);
const pixels = imgData.data;
const bFactor = this.brightness / 100;
const cFactor = this.contrast / 100;
const cMid = 128; // 对比度中点
for (let i = 0; i < pixels.length; i += 4) {
let r = pixels[i];
let g = pixels[i + 1];
let b = pixels[i + 2];
// ---- 滤镜计算 ----
switch (this.currentFilter) {
case '黑白': {
// 灰度公式:0.299R + 0.587G + 0.114B
const gray = 0.299 * r + 0.587 * g + 0.114 * b;
r = gray; g = gray; b = gray;
break;
}
case '怀旧': {
// 棕褐色调(Sepia)矩阵
r = 0.393 * r + 0.769 * g + 0.189 * b;
g = 0.349 * r + 0.686 * g + 0.168 * b;
b = 0.272 * r + 0.534 * g + 0.131 * b;
break;
}
case '反转': {
r = 255 - r;
g = 255 - g;
b = 255 - b;
break;
}
case '冷色': {
r = r * 0.7; // 降低红色
b = Math.min(255, b * 1.3); // 增强蓝色
g = g * 0.9; // 略降绿色
break;
}
case '暖色': {
r = Math.min(255, r * 1.3); // 增强红色
b = b * 0.7; // 降低蓝色
g = g * 0.95; // 略降绿色
break;
}
}
// ---- 亮度调节 ----
r = r * bFactor;
g = g * bFactor;
b = b * bFactor;
// ---- 对比度调节 ----
r = (r - cMid) * cFactor + cMid;
g = (g - cMid) * cFactor + cMid;
b = (b - cMid) * cFactor + cMid;
// ---- 钳位到 [0, 255] ----
pixels[i] = Math.max(0, Math.min(255, r));
pixels[i + 1] = Math.max(0, Math.min(255, g));
pixels[i + 2] = Math.max(0, Math.min(255, b));
// alpha (pixels[i+3]) 保持不变
}
ctx.putImageData(imgData, 0, 0);
}
滤镜数学原理解析
灰度公式:Gray = 0.299R + 0.587G + 0.114B
这是 ITU-R BT.601 标准的亮度公式,人眼对绿色最敏感(权重最高 0.587),对蓝色最不敏感(权重最低 0.114)。
怀旧(Sepia):
R' = 0.393R + 0.769G + 0.189B
G' = 0.349R + 0.686G + 0.168B
B' = 0.272R + 0.534G + 0.131B
这是一种模拟老照片棕褐色调的变换矩阵。R 通道获得较大增益,B 通道被衰减,整体呈现暖黄褐色。
冷色与暖色:通过调整 RGB 通道的比例实现。冷色增强 B 衰减 R,暖色增强 R 衰减 B。
8.5 避免累积失真
每次应用滤镜时,必须从原始数据(this.originalData)重新计算,而不是在上一次滤镜结果上叠加:
// ✅ 正确:每次从原始数据拷贝
const srcData = new Uint8ClampedArray(this.originalData.data);
const imgData = new ImageData(srcData, w, h);
// ❌ 错误:从当前 Canvas 读取(会累积失真)
// const currentData = ctx.getImageData(0, 0, w, h);
8.6 对比度算法详解
对比度调节公式:
r = (r - cMid) * cFactor + cMid;
其中 cMid = 128 是 RGB 值域中点,cFactor = contrast / 100。
cFactor = 1.0(对比度 100%):(r-128)*1+128 = r,无变化cFactor = 1.5(对比度 150%):(r-128)*1.5+128,小于 128 的值更低、大于 128 的值更高,对比度增强cFactor = 0.5(对比度 50%):(r-128)*0.5+128,所有值向 128 靠拢,对比度降低
9. 通用组件与类型定义
9.1 类型定义
/** 绘图的单个点 */
interface DrawPoint {
x: number;
y: number;
}
/** 一笔完整的笔画 */
type DrawStroke = DrawPoint[];
/** 滤镜定义 */
interface FilterDef {
name: string;
icon: string;
}
ArkTS 中 interface 和 type 的行为与 TypeScript 一致。DrawStroke 作为 DrawPoint[] 的类型别名,使代码语义更清晰。
9.2 ActionButton 通用组件
@Component
struct ActionButton {
private label: string = '';
private bgColor: string = '#6C63FF';
private onClickAction: () => void = () => {};
build() {
Button(this.label)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.backgroundColor(this.bgColor)
.borderRadius(20)
.height(36)
.padding({ left: 18, right: 18 })
.onClick(() => { this.onClickAction(); })
}
}
这是一个精心设计的可复用组件:
- 圆角胶囊风格:
borderRadius(20)与height(36)配合,两端呈完美半圆 - 回调委托:通过
onClickAction属性将点击事件交给父组件处理 - 颜色可定制:
bgColor默认紫色,可由父组件自由替换
使用示例:
ActionButton('↩ 撤销', '#FF6B6B', () => this.undo())
ActionButton('🗑 清空', '#845EC2', () => this.clearCanvas())
ActionButton('💾 保存', '#6C63FF', () => this.saveDrawing())
9.3 @Builder 与 @Component 的区别
| 特性 | @Builder | @Component |
|---|---|---|
| 独立生命周期 | 无 | 有 |
| 独立状态管理 | 无(共享父组件状态) | 有(@State 独立) |
| 复用性 | 同一结构体内复用 | 全局复用 |
| 参数传递 | 独立参数(推荐) | 对象属性语法 |
| 适用场景 | 纯展示性 UI 片段 | 有交互逻辑的独立模块 |
10. @State 响应式数据流深度解析
10.1 @State 的工作原理
@State 是 ArkTS 声明式 UI 的核心。当一个变量被 @State 装饰后,编译器会跟踪该变量的读取和写入:
- 读:组件
build()方法中读取@State变量时,会自动注册对该变量的依赖 - 写:
@State变量被修改时,所有依赖它的组件会自动重新执行build()
10.2 @State 数组的特殊行为
ArkTS 对 @State 数组进行了优化。直接调用数组的变异方法(push、pop、splice、sort 等)会被编译器检测到,并触发响应式更新:
@State private items: string[] = ['A', 'B', 'C'];
// 这些操作都会触发 UI 刷新:
this.items.push('D'); // ✅ 新增
const last = this.items.pop(); // ✅ 删除
this.items[0] = 'X'; // ✅ 修改(支持索引位赋值)
this.items.sort(); // ✅ 排序
这与 React 的 useState 不同。React 要求:
// React 中必须创建新数组
setItems([...items, 'D']); // ✅
setItems(items.filter(...)); // ✅
items.push('D'); // ❌ 不会触发重渲染
10.3 本项目中的 @State 使用情况
| 组件 | @State 变量 | 驱动内容 |
|---|---|---|
| CreativeApp | currentIndex |
当前 Tab 索引 |
| DrawPage | currentColor, brushSize, isDrawing |
颜色盘选中态、笔刷滑条、绘图状态 |
| WritePage | content, fontSize, templateIndex |
编辑区文字、字号滑条、模板选中态 |
| PhotoEditPage | hasImage, currentFilter, brightness, contrast, sampleImageLoaded |
滤镜列表、亮度和对比度滑条 |
10.4 常见误区
误区 1:在非 build() 方法中读取 @State 不会建立依赖。
@State private count: number = 0;
private logCount(): void {
console.log(`count = ${this.count}`); // 可以读取值,但不会建立响应式依赖
}
build() {
Text(`${this.count}`) // 这里读取会建立依赖
}
误区 2:修改 @State 对象但未改变引用。
@State private user = { name: 'Alice', age: 25 };
// ✅ 会触发刷新:
this.user = { name: 'Bob', age: 30 }; // 新对象
// ❌ 不会触发刷新:
// this.user.age = 30; // 修改属性不改变引用
注意:ArkTS 编译器对
@State对象的属性修改有特殊处理,某些场景下直接修改属性也会触发刷新。但为了代码一致性,建议尽量使用新对象赋值。
11. Hvigor 构建与调试
11.1 构建命令
HarmonyOS NEXT 使用 hvigorw 作为构建工具(类似 Gradle 之于 Android、Webpack 之于前端)。
# 完整构建
hvigorw assembleHap --no-daemon --mode module -p module=entry
# 清理构建缓存
hvigorw clean --no-daemon
# 仅编译 ArkTS
hvigorw assembleHap --no-daemon --mode module -p module=entry --tasks CompileArkTS
11.2 构建输出解读
> CompileArkTS... after 3 s 159 ms ✓
> PackageHap... after 457 ms ✓
> BUILD SUCCESSFUL in 8 s 157 ms ✓
WARN: Will skip sign 'hos_hap'.
No signingConfigs profile is configured.
签名警告是正常现象。调试运行时,DevEco Studio 会自动使用调试证书签名。发布应用时需在 build-profile.json5 中配置正式签名信息。
11.3 常见构建错误
| 错误信息 | 原因 | 解决 |
|---|---|---|
ArkTS:ERROR File not found |
导入路径错误 | 检查 import 路径 |
ArkTS:ERROR Cannot find name 'XXX' |
类型未定义或未导入 | 添加类型定义或导入 |
AdaptorError: Task not found |
构建命令参数错误 | 检查 task 名称 |
hvigor ERROR: CompileArkTS failed |
ArkTS 语法错误 | 查看具体错误行号 |
12. ArkTS 最佳实践与常见陷阱
12.1 最佳实践清单
-
@Builder 参数使用独立形式参
✅@Builder Foo(name: string, age: number)
❌@Builder Foo({ name, age }: { name: string, age: number }) -
ForEach 必须提供第三个参数(keyGenerator)
ForEach(list, (item) => { Item(item) }, (item) => item.id.toString())keyGenerator 用于优化列表 diff 性能,避免不必要的全量重渲染。
-
Canvas 坐标转换
触摸事件坐标是屏幕坐标,必须通过getBoundingRect()转换为 Canvas 内部坐标。 -
像素滤镜从原始数据开始
每次应用滤镜应从originalData新建拷贝,避免多次叠加导致的精度损失。 -
非 UI 数据不放在 @State 中
不需要驱动 UI 刷新的数据(如笔画历史、Canvas 上下文)应声明为普通私有变量。 -
使用 try-catch 保护系统 API
剪贴板、文件系统等系统 API 在不同设备上可能因权限或其他原因抛出异常。
12.2 常见陷阱
陷阱 1:Tabs 受控模式缺少 onChange
// ❌ index 受控但没有 onChange,点击 Tab 无响应
Tabs({ index: this.currentIndex }) {
TabContent() { PageA() }.tabBar('A')
TabContent() { PageB() }.tabBar('B')
}
// ✅ 必须设置 onChange 更新状态
Tabs({ index: this.currentIndex, onChange: (idx) => { this.currentIndex = idx } })
陷阱 2:在 @Builder 中使用解构参数
// ❌ 旧版写法,新版 ArkTS 编译器报错
@Builder Card({ item, index }: { item: Item, index: number }) { ... }
// ✅ 正确写法
@Builder Card(item: Item, index: number) { ... }
// 调用时:
this.Card(item, index) // 而不是 this.Card({ item, index })
陷阱 3:@State 数组直接修改索引
// 在大多数情况下是支持的,但复杂对象数组建议整体替换
this.items[0] = newItem; // 可能触发刷新
// 更保险的方式:
const newItems = [...this.items];
newItems[0] = newItem;
this.items = newItems; // 引用变化,一定触发刷新
13. 总结与扩展方向
13.1 项目总结
本文通过构建一个包含绘图、文案、修图三大功能的极简创作软件,全面展示了 HarmonyOS NEXT(API 24)下 ArkTS 的开发技术栈:
| 技术点 | 应用场景 | 核心代码位置 |
|---|---|---|
| @Entry @Component | 页面入口和组件化 | CreativeApp.ets 第 19-21 行 |
| Tabs 底部导航 | 三功能切换 | CreativeApp.ets 第 44-66 行 |
| @Builder 构建器 | Tab 图标、颜色盘、工具栏 | 全文件多处 |
| @State 响应式 | UI 状态驱动 | 4 个组件共 10 个 @State |
| Canvas 2D | 绘画绘制与像素滤镜 | DrawPage + PhotoEditPage |
| Flex + wrap + layoutWeight | 卡片流自适应 | FlexCardLayout.ets 第 109-123 行 |
| pasteboard | 系统剪贴板 | WritePage 第 491-503 行 |
| image.ImagePacker | Canvas 编码 PNG | DrawPage 第 347-368 行 |
13.2 扩展方向
本应用已具备基础功能,可以沿以下方向扩展:
-
绘图模块
- 笔画颜色持久化存储(每笔独立颜色)
- 图片插入功能(在 Canvas 上叠加图片)
- 文字工具(在画布上输入文字)
- 导出到相册(使用 mediaLibrary API)
-
文案模块
- 多文档切换(类似标签页)
- 云端同步(接入华为云服务)
- AI 文案生成(接入大模型 API)
- 历史版本追踪
-
修图模块
- 支持从相册选取图片
- 自定义滤镜参数调节
- 裁剪/旋转/缩放
- 图层叠加
-
全局功能
- 主题切换(暗色模式)
- 多语言国际化
- 手势返回
- 无障碍适配
13.3 写在最后
HarmonyOS NEXT 的 ArkTS 语言在声明式 UI 领域展现了优秀的设计理念。与 SwiftUI 的 @State、Jetpack Compose 的 mutableStateOf 相比,ArkTS 的装饰器语法更加直观,对 TypeScript 开发者几乎没有学习成本。Canvas 2D API 的高度兼容性使得 Web 端的绘图知识可以直接迁移。
作为开发者,在鸿蒙原生生态中构建应用时,最重要的是深刻理解「响应式数据流」这一核心概念。一旦掌握了 @State → build() → UI 刷新 这一闭环,ArkTS 开发就变得简单而优雅。
本文所有代码基于 HarmonyOS NEXT 6.1.1(API 24)编写,使用 DevEco Studio 构建调试。代码文件位于 entry/src/main/ets/pages/CreativeApp.ets 和 entry/src/main/ets/pages/FlexCardLayout.ets。
更多推荐



所有评论(0)