从零搭建「极简全能创作 App」—— HarmonyOS NEXT ArkTS 完整开发教程

从零搭建「极简全能创作 App」—— HarmonyOS NEXT ArkTS 完整开发教程
目标读者:有 TypeScript 基础、第一次接触鸿蒙开发的开发者
SDK 版本:HarmonyOS NEXT 6.1.1(API 24)
开发工具:DevEco Studio
源码文件:CreativeApp.ets(910 行)、FlexCardLayout.ets(583 行)
核心覆盖:@Component / @State / @Builder / Canvas 2D / Tabs / Flex 布局 / pasteboard / ImagePacker
前言
本教程带领你从零开始,在 HarmonyOS NEXT 上用 ArkTS 构建一个包含「绘图、写文案、修照片」三大功能的创作 App。你不需要有任何鸿蒙开发经验,只要会 TypeScript 或 JavaScript 就能跟上。
我们将在 910 行代码内完成全部功能,真正做到小而全。每段代码都附有详细解释,确保你不仅「能用」,更「理解为什么这样用」。
第一章:准备工作与环境搭建
1.1 下载 DevEco Studio
DevEco Studio 是华为官方的鸿蒙开发 IDE,基于 IntelliJ IDEA 构建。前往华为开发者官网下载最新版本。
安装后打开,确保 SDK 管理器中已安装 HarmonyOS NEXT 6.1.1(API 24) 的 SDK。
1.2 创建新项目
打开 DevEco Studio,点击 Create Project:
- 选择模板:Empty Ability(空模板)
- Project Name:
CreativeApp - Bundle Name:
com.example.creativeapp - Save Location:自定义
- Compatible SDK:选择 API 24
- Language:ArkTS
创建完成后你会看到项目结构:
CreativeApp/
├── entry/
│ └── src/main/
│ ├── ets/
│ │ ├── entryability/
│ │ │ └── EntryAbility.ets ← 应用入口
│ │ └── pages/
│ │ └── Index.ets ← 默认首页
│ ├── resources/ ← 资源目录
│ └── module.json5 ← 模块配置
├── build-profile.json5 ← 构建配置
└── hvigorfile.ts ← 构建脚本
1.3 认识 ArkTS 文件结构
在鸿蒙开发中,一个页面就是一个 .ets 文件。每个页面的基本骨架如下:
@Entry // ← 标记这是一个页面入口
@Component // ← 标记这是一个组件
struct MyPage { // ← 结构体(类似 class)
@State message: string = ''; // ← 响应式状态
build() { // ← UI 描述(类似 React 的 render)
Column() { // ← 垂直布局容器
Text(this.message) // ← 文本组件
.fontSize(20) // ← 链式 API 设置属性
}
}
}
对照 React 来理解:
@Component≈function Component()@State≈useState()build()≈render() / return (JSX)- 链式 API
.width().height()≈ JSX 的style={{ width, height }}
第二章:应用入口 —— EntryAbility
2.1 UIAbility 生命周期
EntryAbility.ets 是应用的启动入口,继承自 UIAbility(类似 Android 的 Activity 或 iOS 的 UIApplication)。
import { AbilityConstant, 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 {
hilog.info(DOMAIN, 'testTag', 'Ability onCreate');
}
// 窗口创建完成 —— 在此加载首页
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/CreativeApp', (err) => {
if (err.code) {
hilog.error(DOMAIN, 'testTag',
'Failed to load content. Cause: %{public}s', JSON.stringify(err));
return;
}
});
}
// Ability 销毁
onDestroy(): void {
hilog.info(DOMAIN, 'testTag', 'Ability onDestroy');
}
// 进入前台
onForeground(): void {
hilog.info(DOMAIN, 'testTag', 'Ability onForeground');
}
// 进入后台
onBackground(): void {
hilog.info(DOMAIN, 'testTag', 'Ability onBackground');
}
}
loadContent('pages/CreativeApp', callback) 是关键一行。它告诉系统去加载 pages/ 目录下名为 CreativeApp.ets 的文件。路径中的 'pages/CreativeApp' 对应文件 pages/CreativeApp.ets,注意不需要写文件扩展名。
2.2 loadContent 回调的妙用
回调函数接收 err 对象。如果加载失败(比如文件不存在、语法错误),err.code 不为 0。调试阶段养成检查 err.code 的习惯,能快速定位页面加载问题。
第三章:构建 Tabs 底部导航框架
3.1 设计思路
我们的 App 有三个独立功能模块:绘图、写文案、修照片。最适合这种场景的导航方式就是 底部 Tab 栏。ArkTS 的 Tabs 组件原生支持这种设计模式。
整体架构如下:
CreativeApp (主入口)
├── TopBar (标题栏)
└── Tabs (底部导航)
├── Tab 0: DrawPage (绘图)
├── Tab 1: WritePage (写文案)
└── Tab 2: PhotoEditPage (修照片)
3.2 主入口组件
新建 pages/CreativeApp.ets,写入以下代码:
import { promptAction } from '@kit.ArkUI';
import { pasteboard } from '@kit.BasicServicesKit';
import { image } from '@kit.ImageKit';
@Entry
@Component
struct CreativeApp {
@State private currentIndex: number = 0;
build() {
Column({ space: 0 }) {
// ---- 顶栏 ----
this.TopBar()
// ---- Tab 内容区 ----
Tabs({
barPosition: BarPosition.End, // 标签栏在底部
index: this.currentIndex, // 当前选中哪个 Tab(受控模式)
onChange: (idx: number) => { // Tab 切换回调
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')
}
.width('100%')
.height('100%')
}
// ---- 顶栏 ----
@Builder
TopBar() {
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')
}
// ---- 底部 Tab 图标 + 文字 ----
@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)
}
}
3.3 Tabs 组件详解
barPosition: BarPosition.End
控制 Tab 栏的位置:
BarPosition.End→ 底部 Tab(移动端最常见)BarPosition.Start→ 顶部 Tab
受控模式(Controlled Mode)
Tabs({
index: this.currentIndex, // 读取状态
onChange: (idx) => { // 写入状态
this.currentIndex = idx;
}
})
这里的 index 和 onChange 必须同时设置。只设置 index 而不处理 onChange,用户点击 Tab 时界面不会切换。这是 ArkTS 新手最容易踩的坑。
@State private currentIndex 是「受控状态」—— Tab 的选中状态不由 Tabs 内部管理,而是由父组件通过状态变量控制。
3.4 @Builder 构建器
@Builder 是 ArkTS 的一种轻量级 UI 构建函数,与 @Component 的区别在于:
| 特性 | @Builder | @Component |
|---|---|---|
| 状态管理 | 无独立状态 | 有 @State |
| 参数方式 | 独立参数 | 对象属性 |
| 复用范围 | 同一结构体内 | 全局 |
| 性能 | 零开销 | 轻微 |
我们定义了两个 @Builder:
TopBar():顶部标题栏,无参数TabBarItem(icon, label, index):底部 Tab 项目,三个参数
3.5 Tab 选中态高亮
.fontColor(this.currentIndex === index ? '#6C63FF' : '#999')
这是 ArkTS 中常见的「条件样式」写法:三元表达式直接在属性链中计算。当 currentIndex === index 时为紫色高亮,其余为灰色。
3.6 Column 与 Row 布局
ArkTS 最基本的两个布局容器:
Column:子元素垂直排列(类似 CSS flex-direction: column)Row:子元素水平排列(类似 CSS flex-direction: row)
参数 { space: 10 } 控制子元素间距,等价于 CSS 的 gap: 10px。
第四章:模块一 —— 绘图页面(DrawPage)
4.1 功能需求
绘图页面需要支持:
- 手指在画布上绘画
- 12 种预设颜色可以切换
- 笔刷大小 1~20px 可调
- 支持撤销操作
- 一键清空画布
- 将画作保存为图片
4.2 Canvas 2D API 简介
ArkTS 的 CanvasRenderingContext2D 与 Web 标准 Canvas API 高度一致。如果你有 Web 开发经验,可以直接上手。核心方法对照:
| Web Canvas | ArkTS Canvas | 说明 |
|---|---|---|
ctx.beginPath() |
相同 | 开始一条新路径 |
ctx.moveTo() |
相同 | 移动画笔到指定点 |
ctx.lineTo() |
相同 | 画线到指定点 |
ctx.stroke() |
相同 | 描边路径 |
ctx.fill() |
相同 | 填充路径 |
canvas.getContext('2d') |
new CanvasRenderingContext2D(settings) |
获取上下文 |
canvas.getBoundingClientRect() |
不需要(触摸坐标已相对组件) | 获取位置 |
4.3 DrawPage 状态定义
@Component
struct DrawPage {
// Canvas 上下文(实例变量,不驱动 UI)
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: DrawPoint[] = [];
private currentStroke: DrawPoint[] = [];
// 颜色盘(不驱动 UI)
private readonly presetColors: string[] = [
'#1A1A2E', '#6C63FF', '#FF6B6B', '#FFD93D',
'#6BCB77', '#4D96FF', '#FF6F91', '#845EC2',
'#FF9671', '#00C9A7', '#FFC75F', '#F9F9F9',
];
// ...
}
为什么有些变量用 @State,有些不用?
这是一个关键的设计决策:
@State变量(currentColor、brushSize、isDrawing):UI 直接依赖这些值。颜色选中态、滑条数值、工具栏文字都需要根据它们渲染。修改它们会触发build()重执行。- 普通变量(
strokes、currentStroke、settings、context):UI 不直接展示笔画列表。修改它们不会触发 UI 刷新,而是通过 Canvas API 手动操作。
教训:不要把 Canvas 上下文或大型数据结构放在 @State 中。它们不需要驱动 UI,放进去反而会造成不必要的性能开销。
4.4 构建 UI
build() {
Column({ space: 10 }) {
this.Toolbar() // 工具栏
this.CanvasArea() // 画布区
this.ColorBrushBar() // 颜色盘 + 笔刷
this.ActionButtons() // 操作按钮
}
.width('100%')
.padding(16)
.alignItems(HorizontalAlign.Center)
}
将 UI 拆分为四个 @Builder 方法,每个方法负责一块独立区域:
工具栏:显示当前笔触大小和颜色
@Builder
Toolbar() {
Row({ space: 6 }) {
Text('🖌️ 手指绘画').fontSize(15).fontWeight(FontWeight.Medium)
Text(' | ').fontColor('#DDD')
Text(`笔触 ${this.brushSize}px · 颜色 ${this.currentColor}`)
.fontSize(12).fontColor('#AAA')
}
.width('100%')
.padding({ left: 4, bottom: 4 })
}
画布区:核心绘图区域
@Builder
CanvasArea() {
Stack() {
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(); }) // Canvas 就绪后画网格
.onTouch((event: TouchEvent) => { // 触摸事件
this.handleCanvasTouch(event);
})
}
.width('100%')
.aspectRatio(1.1)
}
Canvas 组件通过 CanvasRenderingContext2D 对象连接。四个事件回调:
| 回调 | 触发时机 | 用途 |
|---|---|---|
onReady |
Canvas 首次加载完成 | 初始化绘制 |
onTouch |
触摸事件(Down/Move/Up) | 手指绘画 |
onAreaChange |
Canvas 尺寸变化 | 响应式适配 |
onKeyEvent |
键盘事件 | 快捷键 |
4.5 触摸事件处理 —— 手指绘画的核心
private handleCanvasTouch(event: TouchEvent): void {
// 在 ArkUI 中,touches[0].x / .y 已是相对于组件的坐标
const x = event.touches[0].x;
const y = event.touches[0].y;
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;
}
}
坐标说明
在 ArkUI 中,event.touches[0].x 返回的坐标已经是相对于触发触摸的组件的坐标。不需要像 Web 开发那样调用 getBoundingClientRect() 来转换。
笔画记录机制
我们用一个「笔画历史栈」来支持撤销:
触摸 Down → 新建 currentStroke 数组
触摸 Move → 每次往 currentStroke 里 push 一个点
触摸 Up → currentStroke 完整一笔保存到 strokes 数组
撤销 → strokes.pop() 然后清空画布从零重绘
这个「三阶段记录法」把连续的触摸事件拆分成离散的「笔画」单元,每笔是一个独立的 DrawPoint 数组,撤销时退回的就是一整个笔画。
4.6 绘制背景网格
private drawGrid(): void {
const ctx = this.context;
const w = ctx.canvas.width;
const h = ctx.canvas.height;
ctx.save();
ctx.strokeStyle = '#F0F0F5';
ctx.lineWidth = 0.5;
for (let i = 0; i < w; i += 30) {
ctx.beginPath();
ctx.moveTo(i, 0);
ctx.lineTo(i, h);
ctx.stroke();
}
for (let i = 0; i < h; i += 30) {
ctx.beginPath();
ctx.moveTo(0, i);
ctx.lineTo(w, i);
ctx.stroke();
}
ctx.restore();
}
save() / restore() 是 Canvas 的状态保存与恢复机制。save() 保存当前绘图状态(线条颜色、宽度、样式等)到栈中,restore() 恢复之前保存的状态。使用这对方法可以确保绘制网格时修改的样式不会污染后续绘画。
4.7 颜色盘与笔刷滑条
颜色盘:用 Flex + FlexWrap.Wrap 实现流式排列,12 种颜色自动折行显示:
@Builder
ColorBrushBar() {
Column({ space: 8 }) {
// ---- 颜色盘 ----
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; })
})
}
.width('100%')
// ---- 笔刷滑条 ----
Row({ space: 10 }) {
Text('笔刷').fontSize(13).fontColor('#666')
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; })
Text(`${this.brushSize}`)
.fontSize(12).fontColor('#6C63FF')
.fontWeight(FontWeight.Bold)
}
}
.width('100%')
.padding(12)
.backgroundColor(Color.White)
.borderRadius(16)
.shadow({ radius: 4, color: 'rgba(0,0,0,0.04)', offsetY: 2 })
}
颜色盘的设计细节:
- 每个色块是一个 32x32 的圆形(
borderRadius(16)) - 当前选中的颜色高亮边框(
#6C63FF,宽度 2.5px) - 选中态显示白色
✓标记 #F9F9F9(白色)色块的特殊处理:文字颜色改为#333
Slider 组件关键属性:
value:当前值(双向绑定)min/max:取值范围step:步进值(本例 step=1,每次变化 1px)style:SliderStyle.OutSet(凸起风格)或SliderStyle.InSet(内嵌风格)blockColor/trackColor/selectedColor:滑块三色定制
4.8 撤销功能
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();
}
}
撤销的算法可以概括为:「栈弹出 + 全量重绘」。这不是最高效的方式,但胜在逻辑简单、易于理解。对于本应用的规模(几十到上百笔),性能完全够用。
4.9 保存画作为图片
这是 ArkTS Canvas 的特色功能 —— getPixelMap() 方法将 Canvas 当前帧转为 PixelMap,再通过 image.ImagePacker 编码为 PNG 二进制数据:
import { image } from '@kit.ImageKit';
private saveDrawing(): void {
const ctx = this.context;
// 1. Canvas → PixelMap
const pixelMap = ctx.getPixelMap(0, 0, ctx.canvas.width, ctx.canvas.height);
if (!pixelMap) {
promptAction.showToast({ message: '⚠️ 保存失败,请重试', duration: 1500 });
return;
}
// 2. PixelMap → PNG ArrayBuffer
const packer: image.ImagePacker = image.createImagePacker();
const packOpts: image.PackingOption = { format: 'image/png', quality: 100 };
const arrayBuffer: ArrayBuffer = packer.packing(pixelMap, packOpts);
packer.release(); // 释放编码器资源
// 3. 显示文件大小
const sizeKB: number = Math.round(arrayBuffer.byteLength / 1024);
promptAction.showToast({
message: `✅ 画作已保存!(${sizeKB}KB)`,
duration: 2000,
});
}
三步流程:
Canvas 当前帧
↓ getPixelMap(x, y, width, height)
PixelMap(像素图)
↓ image.ImagePacker.packing(pixelMap, options)
ArrayBuffer(PNG 二进制数据)
↓ fileIo / mediaLibrary(写入文件系统)
图片文件
本示例只走到 ArrayBuffer 并以 Toast 显示大小。实际如需写入相册,可以使用 @ohos.file.fs 或 @ohos.multimedia.mediaLibrary。
第五章:模块二 —— 写文案页面(WritePage)
5.1 功能需求
- 文字输入与编辑
- 实时字数统计
- 字号可调(12~28px)
- 一键复制到剪贴板
- 6 种快捷模板一键填充
5.2 状态与 UI 结构
@Component
struct WritePage {
@State private content: string = ''; // 文案内容
@State private fontSize: number = 16; // 字号
@State private templateIndex: number = -1; // 当前选中的模板索引
private readonly templates: string[] = [
'✨ 生活的诗意,藏在每一个认真的日子里。\n—— 极简创作 · 每日一句',
'🎯 新品上市!限时特惠,错过等一年~\n点击链接立即抢购 👉',
'📸 镜头定格的瞬间,是时光写给未来的情书。',
'💪 自律不是束缚,而是通往自由的钥匙。\n今天也要加油鸭! 🦆',
'🌅 早安!新的一天,新的可能。\n愿你心中有光,脚下有路。',
'🎉 感恩遇见,感谢相伴。\n这份心意,只给最特别的你。',
];
}
5.3 TextArea 多行文本输入
TextArea({
text: this.content, // 绑定 @State 变量
placeholder: '在这里输入文案……\n💡 点击下方「快捷模板」一键填充',
})
.width('100%')
.height(240)
.fontSize(this.fontSize) // 字号跟随 @State
.backgroundColor(Color.White)
.borderRadius(16)
.padding(14)
.placeholderFont({ size: 13, color: '#CCC' })
.onChange((val: string) => { this.content = val; })
TextArea vs TextInput:
TextArea:多行文本,适合段落式文案TextInput:单行文本,适合用户名/搜索框
双向绑定:text: this.content + onChange 组合实现了类似 React 受控组件的效果:
- 用户输入 →
onChange触发 →this.content更新 → UI 重新渲染 @State content被修改 → 界面上文字内容同步更新
5.4 实时字数统计
Row() {
Text('✍️ 开始创作你的文案').fontSize(15).fontWeight(FontWeight.Medium)
Blank() // 占据剩余空间,将右侧内容推到末尾
Text(`${this.content.length} 字`) // 利用 @State content 实时计算
.fontSize(12)
.fontColor('#AAA')
}
${this.content.length} 字 是一个计算属性表达式。每次 this.content 变化(用户输入、点击模板)时,content.length 重新计算,UI 自动刷新。这就是声明式 UI 的魔力——你不需要手动更新「字数」这个 DOM 节点,框架会自动追踪依赖并执行最小粒度的更新。
5.5 系统剪贴板
import { pasteboard } from '@kit.BasicServicesKit';
private copyToClipboard(): void {
if (!this.content.trim()) {
promptAction.showToast({ message: '⚠️ 还没有内容可以复制', duration: 1200 });
return;
}
try {
// 1. 创建纯文本 PasteData
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 });
}
}
为什么要用 try-catch?
系统 API 在不同设备、不同权限配置下可能抛出异常。例如用户在设置中关闭了剪贴板读取权限。用 try-catch 包裹系统调用,可以在异常时给出友好的提示,而不是让应用崩溃。
剪贴板 API 流程:
1. createData(mimeType, text) → 构造剪贴板数据对象
2. getSystemPasteboard() → 获取系统剪贴板实例(单例)
3. setPasteData(data) → 写入剪贴板
pasteboard.MIMETYPE_TEXT_PLAIN 表示纯文本类型。还支持 MIMETYPE_TEXT_HTML 等格式。
5.6 快捷模板
模板按钮采用胶囊式设计(borderRadius(20)),用 Flex + wrap 实现自动折行:
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;
this.content = tpl; // 填充模板内容到编辑区
})
})
}
关键设计:
- 模板文字超过 18 个字截断加
…,保持按钮整洁 - 选中态背景紫色(
#6C63FF),非选中淡紫(#F0F0FF) - 点击时同时更新
templateIndex(选中态)和content(编辑区内容)
第六章:模块三 —— 修照片页面(PhotoEditPage)
6.1 功能需求
- 加载示例图片(无需外部文件,Canvas 绘制生成)
- 6 种滤镜效果:原图 / 黑白 / 怀旧 / 反转 / 冷色 / 暖色
- 亮度调节(30%~200%)
- 对比度调节(30%~200%)
- 重置为原图
- 保存修图结果
6.2 像素滤镜原理
修照片功能的技术核心是 Canvas 像素级操作。一张数字图片由百万个像素组成,每个像素有四个通道值:R(红)、G(绿)、B(蓝)、A(透明度),取值范围 0~255。
滤镜的本质就是修改每个像素的 RGB 值:
读取像素 → 数学变换 → 写回像素
[原始像素] [滤镜公式] [处理后像素]
R=200, G=100 黑白滤镜 R=128, G=128
B=50, A=255 ----→ B=128, A=255
6.3 状态定义
@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: '🟠' },
];
}
6.4 加载示例图片
我们不需要外部图片文件,直接用 Canvas API 绘制一幅完整的彩色风景图:
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. 树木(5 棵三角形树) ----
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. 水印文字 ----
ctx.font = `bold ${Math.round(w * 0.06)}px sans-serif`;
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.textAlign = 'center';
ctx.fillText('🌄 示例风景', w / 2, h * 0.92);
// ---- 6. 保存原始像素 ----
this.originalData = ctx.getImageData(0, 0, w, h);
this.hasImage = true;
this.sampleImageLoaded = true;
this.currentFilter = '原图';
this.brightness = 100;
this.contrast = 100;
}
Canvas 绘图技巧:
shadowBlur + shadowColor 实现发光效果。太阳绘制两次:第一次是常规填充,第二次带阴影辉光。记得在辉光填充后将 shadowBlur 重置为 0,避免后续绘制受影响。
globalAlpha 控制全局透明度。绘制山峦时设为 0.3(半透明),绘制树木时设为 0.5,绘制结束后恢复为 1。
6.5 像素滤镜算法
这是全应用技术含量最高的部分。我们逐行解析:
private applyCurrentFilter(): void {
if (!this.hasImage || !this.sampleImageLoaded || !this.originalData) return;
const ctx = this.context;
const w = this.canvasWidth;
const h = this.canvasHeight;
// 从原始像素数据拷贝,避免累积失真
const srcData = new Uint8ClampedArray(this.originalData.data);
const imgData = new ImageData(srcData, w, h);
const pixels = imgData.data;
const bFactor = this.brightness / 100; // 亮度系数
const cFactor = this.contrast / 100; // 对比度系数
const cMid = 128; // RGB 中点值
// 遍历每个像素(每 4 个值为一组:R, G, B, A)
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 '黑白': {
// 灰度公式:根据人眼对 RGB 的敏感度加权平均
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;
// ========== 对比度调节(以中点 128 为支点拉伸/压缩) ==========
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]) 保持不变
}
// 写回 Canvas
ctx.putImageData(imgData, 0, 0);
}
逐行算法详解
灰度公式:
Gray = 0.299 × R + 0.587 × G + 0.114 × B
这是 ITU-R BT.601 国际标准的亮度公式。人眼对绿色最敏感(权重 0.587),对蓝色最不敏感(权重 0.114)。这比简单平均((R+G+B)/3)更符合人眼感知。
怀旧(Sepia)矩阵:
R' = 0.393R + 0.769G + 0.189B
G' = 0.349R + 0.686G + 0.168B
B' = 0.272R + 0.534G + 0.131B
这是一个 3×3 颜色矩阵变换。R 通道获得来自 G 和 B 的贡献(0.769G + 0.189B),B 通道被显著衰减(系数仅 0.131),整体呈现暖黄色调。
对比度公式:
R' = (R - 128) × cFactor + 128
以 128 为「支点」:
cFactor > 1(对比度 > 100%):小于 128 的值向 0 偏移,大于 128 的值向 255 偏移 → 对比度增强cFactor < 1(对比度 < 100%):所有值向 128 靠拢 → 对比度降低cFactor = 1(对比度 = 100%):不变
钳位操作:
Math.max(0, Math.min(255, value))
RGB 值必须严格在 0~255 之间。亮度、对比度、滤镜计算都可能产生超出范围的值(如 r * 1.3 > 255),钳位操作确保像素数据合法。
6.6 避免累积失真
每次应用滤镜时,必须从原始数据重新计算,而不是在上次结果上叠加:
// ✅ 正确:从原始数据拷贝
const srcData = new Uint8ClampedArray(this.originalData.data);
const imgData = new ImageData(srcData, w, h);
// ❌ 错误:从当前 Canvas 读取(会累积失真)
// const currentData = ctx.getImageData(0, 0, w, h);
// const pixels = currentData.data; // 如果在已处理过的像素上再次计算,误差会越来越大
为什么不能累积?
每次滤镜计算涉及浮点运算和钳位,都存在精度损失。如果在已处理过的像素上再次套用滤镜,每次的微小误差会不断累积放大。几次操作后,图片就会严重失真。解决方案就是始终保留一份原始数据副本。
第七章:通用组件与类型定义
7.1 类型定义
/** 绘图的单个点 */
interface DrawPoint {
x: number;
y: number;
}
/** 一笔完整的笔画 */
type DrawStroke = DrawPoint[];
/** 滤镜定义 */
interface FilterDef {
name: string;
icon: string;
}
ArkTS 中 interface 和 type 的行为与标准 TypeScript 一致。DrawStroke 作为 DrawPoint[] 的类型别名,可以让代码语义更清晰。
7.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默认紫色,父组件可自由替换
使用示例:
Row({ space: 14 }) {
ActionButton('↩ 撤销', '#FF6B6B', () => this.undo())
ActionButton('🗑 清空', '#845EC2', () => this.clearCanvas())
ActionButton('💾 保存', '#6C63FF', () => this.saveDrawing())
}
第八章:Flex 响应式卡片流布局(FlexCardLayout)
8.1 为什么需要响应式卡片流
在移动端开发中,商品列表、图片墙的自适应排列是一个高频需求。传统做法需要:
- 监听屏幕宽度变化
- 计算每行显示多少列
- 为不同宽度编写不同的样式
在 ArkTS 中,Flex + wrap + layoutWeight 的组合可以零代码实现完全自适应的卡片流布局。
8.2 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%')
FlexWrap.Wrap 的作用:当子元素的总宽度超过容器宽度时,自动折行。这是「流式布局」的基石。
8.3 layoutWeight 响应式等分
每张卡片设置 .layoutWeight(1),表示所有卡片权重相等,行内剩余空间被均分给每张卡片:
@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 })
Text(`¥${item.price}`)
.fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FF6B81')
}
.layoutWeight(1) // ← 核心:等分
.constraintSize({ minWidth: 100, maxWidth: 200 }) // ← 边界保护
.margin(6)
.backgroundColor(Color.White)
.borderRadius(14)
.shadow({ radius: 6, color: 'rgba(0,0,0,0.06)', offsetY: 3 })
}
layoutWeight 的工作机制:
Flex 容器宽度 = 375dp
卡片 1 宽度 = 150dp(由 layoutWeight 计算得到)
卡片 2 宽度 = 150dp
剩余宽度 75dp → 按权重均分 → 每张卡片再分 37.5dp
最终每张卡片 = 187.5dp
加上 constraintSize({ minWidth, maxWidth }) 的限制:
| 设备宽度 | 每行列数 | 排列效果 |
|---|---|---|
| 360dp(手机) | 3 列 | 三列紧凑排列 |
| 600dp(大屏) | 4~5 列 | 宽松排列 |
| 800dp+(平板) | 5~6 列 | 充分利用宽屏 |
完全不需要编写任何断点。这是 ArkTS Flex 布局优于传统 CSS Grid/Flexbox 的地方——盒模型是原生的响应式单位。
第九章:@State 响应式数据流深入理解
9.1 声明式 UI 的核心思想
ArkTS 的响应式数据流遵循一个简单的闭环:
@State 变量被修改
↓
框架自动追踪依赖该变量的组件
↓
组件重新执行 build()
↓
UI 更新
↓
等待下一次 @State 修改
开发者只需要修改 @State 变量,框架负责 UI 更新。这比命令式编程(document.getElementById('xxx').innerText = newValue)效率更高、代码更简洁。
9.2 @State 数组的特殊行为
ArkTS 对 @State 数组进行了深度优化。直接调用数组的变异方法可以触发 UI 刷新:
@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 中:数组变异方法不会触发重渲染
items.push('D'); // ❌ 不会更新 UI
setItems([...items, 'D']); // ✅ 必须创建新数组
ArkTS 在编译阶段检测 @State 数组的 push、pop、splice 等调用,自动插入响应式更新代码。这使得代码更接近直觉——直接修改数据即可。
9.3 本项目中的 @State 使用
| 组件 | @State 变量 | 驱动内容 |
|---|---|---|
| CreativeApp | currentIndex |
Tab 切换 |
| DrawPage | currentColor, brushSize, isDrawing |
颜色盘高亮、笔刷滑条值、工具栏文字 |
| WritePage | content, fontSize, templateIndex |
编辑区文字、字数统计、模板选中态 |
| PhotoEditPage | hasImage, currentFilter, brightness, contrast |
占位图显示隐藏、滤镜选中态、滑条值 |
第十章:构建与运行
10.1 构建命令
在项目根目录打开终端,使用 hvigorw 构建:
# 完整构建(生成 HAP 包)
hvigorw assembleHap --no-daemon --mode module -p module=entry
# 清理构建缓存
hvigorw clean --no-daemon
10.2 构建输出解读
> CompileArkTS... after 3 s 159 ms ← ArkTS 编译通过
> PackageHap... after 457 ms ← 打包成功
> BUILD SUCCESSFUL in 8 s 157 ms ← 构建成功
WARN: Will skip sign 'hos_hap'. ← 签名警告(调试环境正常)
No signingConfigs profile is configured.
签名警告:调试阶段可以忽略。发布应用时需在 build-profile.json5 中配置正式签名信息。
10.3 常见错误及解决
| 错误 | 可能原因 | 解决 |
|---|---|---|
CompileArkTS failed |
语法错误 | 查看具体错误行号 |
Cannot find name 'XXX' |
类型未导入 | 添加 import 语句 |
@Builder parameter error |
@Builder 使用了解构参数 | 改为独立参数形式 |
Task not found |
构建命令错误 | 检查 task 名称拼写 |
Module not found |
导入路径错误 | 检查文件路径和模块名 |
第十一章:最佳实践与常见陷阱
11.1 最佳实践
1. @Builder 参数使用独立形式参
// ✅ 正确
@Builder Card(name: string, price: number) { ... }
this.Card('商品', 99)
// ❌ 错误(新版本不支持)
@Builder Card({ name, price }: { name: string, price: number }) { ... }
this.Card({ name: '商品', price: 99 })
2. ForEach 必须提供 keyGenerator
// keyGenerator(第三个参数)帮助框架追踪列表项,避免全量重渲染
ForEach(list, (item) => { Item(item) }, (item) => item.id.toString())
3. 非 UI 数据不放 @State
不需要驱动 UI 刷新的数据(Canvas 上下文、临时缓存)声明为普通私有变量。
4. 系统 API 用 try-catch 包裹
剪贴板、文件系统等系统 API 可能因权限或设备差异抛出异常。
5. Tabs 受控模式双向绑定
Tabs({ index: this.currentIndex, onChange: (idx) => { this.currentIndex = idx } })
// index 和 onChange 必须同时设置
11.2 常见陷阱
陷阱 1:混淆 @Builder 和 @Component 的传参方式
// @Builder 调用传递独立参数
this.TabBarItem('🎨', '绘图', 0)
// @Component 调用传递对象属性
SectionHeader({ title: '标题', subtitle: '副标题' })
陷阱 2:Canvas 坐标未转换
在 ArkUI 中,event.touches[0].x 已相对于组件本身,不需要额外转换。
陷阱 3:像素滤镜未从原始数据拷贝
每次应用滤镜必须从 originalData 新建 Uint8ClampedArray 拷贝,避免多次叠加导致的精度损失。
陷阱 4:@State 对象属性修改
// 推荐的做法:整体替换对象
this.user = { ...this.user, name: 'NewName' };
// 直接修改属性在某些场景可能不触发刷新
// this.user.name = 'NewName'; // 不推荐
第十二章:扩展方向
本教程构建的应用已具备完整的三大功能,以下是一些扩展方向供你继续探索:
绘图模块扩展
- 保存画作到系统相册(使用
@ohos.multimedia.mediaLibrary) - 每笔独立记录颜色和笔刷大小
- 插入背景图片
- 文字工具
- 导出为 SVG 矢量格式
文案模块扩展
- 多文档管理(类似标签页)
- 字体选择(使用系统字体列表)
- 排版工具(居中对齐、列表格式)
- 版本历史(撤销/恢复)
- 接入大模型 API 智能生成文案
修图模块扩展
- 从系统相册读取图片(使用
photoAccessHelper) - 自定义色温/饱和度调节
- 裁剪工具(在 Canvas 上绘制裁剪框)
- 贴纸叠加
- 滤镜预设组合
全局功能
- 暗色模式适配(通过
@Styles和@Extend管理主题) - 国际化多语言
- 手势返回(结合
onBackPress) - 分享功能(通过
@ohos.want拉起系统分享)
结语
通过本教程,你从零构建了一个包含绘图、文案、修图三大功能的鸿蒙原生应用,掌握了 ArkTS 的核心概念:
| 概念 | 理解程度 |
|---|---|
| @Entry / @Component | 能创建页面和组件 |
| @State 响应式数据 | 理解数据驱动 UI 的闭环 |
| @Builder 构建函数 | 能封装可复用的 UI 片段 |
| Tabs 底部导航 | 掌握受控模式 |
| Canvas 2D 绘图 | 能实现手指绘画和像素滤镜 |
| Flex + wrap + layoutWeight | 能构建自适应响应式布局 |
| pasteboard 剪贴板 | 能读写系统剪贴板 |
| image.ImagePacker | 能编码 Canvas 为图片 |
910 行代码,三大创作功能,零外部依赖——这就是 HarmonyOS NEXT ArkTS 的开发效率。希望这份教程能成为你鸿蒙开发之路的起点。
本文所有代码基于 HarmonyOS NEXT 6.1.1(API 24)编写。源码位于项目 entry/src/main/ets/pages/CreativeApp.ets(910 行)及 FlexCardLayout.ets(583 行)。
更多推荐




所有评论(0)