
在移动应用开发中,针对特定使用场景进行界面适配与交互优化是提升用户体验的关键。本文将深入解析一款基于鸿蒙OS的汉字书写练习应用,该应用通过横屏布局设计、Canvas绘图技术与文件操作功能,为用户打造了沉浸式的汉字书写学习环境。以下从技术实现、核心功能与优化策略三个维度展开详细解析。
一、横屏初始化与界面架构设计
1.1 横屏显示的强制实现
应用在页面加载阶段通过window
模块强制设置为横屏模式,这一设计充分考虑了汉字书写对宽屏空间的需求:
// 页面初始化横屏设置
aboutToAppear(): void {
window.getLastWindow(getContext()).then((windowClass) => {
windowClass.setPreferredOrientation(window.Orientation.LANDSCAPE)
})
}
这段代码通过window.Orientation.LANDSCAPE
参数将屏幕方向固定为横屏,确保应用启动时即呈现宽屏界面。在鸿蒙OS中,这种实现方式比传统的配置文件设置更具灵活性,可根据页面需求动态切换屏幕方向。
1.2 组件化架构设计
应用采用三层组件架构,各组件职责明确:
- CalligraphyPractice:主入口组件,负责整体布局、状态管理与导航控制
- BottomText:底部汉字选择组件,实现汉字项的视觉呈现与交互
- FinishPage:完成页组件,用于展示用户书写成果并提供重新练习入口
这种架构遵循了ArkTS的声明式UI设计理念,通过@Component
装饰器定义组件,以build()
方法描述UI结构,实现了代码的高内聚低耦合。
二、Canvas绘图核心功能实现
2.1 毛笔书写效果的算法实现
应用通过Canvas的onTouch
事件监听实现手写轨迹捕捉,并通过数学算法模拟毛笔书写的粗细变化:
// 触摸事件处理 - 实现毛笔书写效果
onTouch((event) => {
const touch = event.touches[0];
switch (event.type) {
case TouchType.Down:
this.context.beginPath();
this.context.moveTo(touch.x, touch.y);
break;
case TouchType.Move:
// 计算触摸点与画布中心的距离
const distance = Math.sqrt(
(touch.x - this.canvasWidth/2) ** 2 +
(touch.y - this.canvasHeight/2) ** 2
);
// 根据距离动态调整笔刷粗细(距离越远笔触越细)
const brushWidth = this.selectedWidth * (1 - distance / Math.max(this.canvasWidth, this.canvasHeight) * 0.3);
this.context.lineWidth = brushWidth > 5 ? brushWidth : 5;
this.context.strokeStyle = this.modeIndex === 0 ? this.selectedColor : 'white';
this.context.lineTo(touch.x, touch.y);
this.context.stroke();
break;
}
});
该算法的核心逻辑是通过Math.sqrt
计算触摸点与画布中心的欧氏距离,再通过线性变换将距离映射为笔刷粗细值。当笔触靠近画布中心时,线条更粗,模拟毛笔按压的效果;当笔触远离中心时,线条变细,还原毛笔提笔的质感。
2.2 米字格辅助线的几何绘制
为帮助用户掌握汉字结构,应用通过几何计算绘制米字格辅助线:
// 米字格顶点坐标计算
getPoints = (r: number, l: number) => {
const points: number[][] = [[], [], [], []];
points[0] = [r - Math.sqrt(r * r / 2), r - Math.sqrt(r * r / 2)];
points[1] = [l - points[0][0], points[0][1]];
points[2] = [points[1][1], points[1][0]];
points[3] = [l - points[0][0], l - points[0][1]];
return points;
}
// 辅助线绘制
drawGuideLines = (ctx: CanvasRenderingContext2D, r: number) => {
const width = ctx.width;
const height = ctx.height;
const points = this.getPoints(r, width);
// 绘制两条对角线、竖中线与横中线
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
ctx.lineTo(width, height);
ctx.strokeStyle = '#D2B48C';
ctx.lineWidth = 1;
ctx.stroke();
// 省略其他三条线的绘制代码...
}
这里通过Math.sqrt(r * r / 2)
计算等腰直角三角形的边长,确定米字格对角线的顶点坐标,再通过Canvas的beginPath
与stroke
方法绘制辅助线。浅棕色的线条(#D2B48C
)既提供了视觉参考,又不会过度干扰用户书写。
三、文件操作与状态管理实现
3.1 书写内容的沙箱存储
应用通过fileIo
模块将书写内容保存至设备沙箱目录:
// 图片保存至沙箱
savePicture(img: string, n: number) {
// 生成唯一文件名(时间戳+后缀)
const imgPath = getContext().tempDir + '/' + Date.now() + '.jpeg';
const file = fileIo.openSync(imgPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE);
// 解析Base64图像数据
const base64Image = img.split(';base64,').pop();
const imgBuffer = buffer.from(base64Image, 'base64');
// 写入文件并关闭句柄
fileIo.writeSync(file.fd, imgBuffer.buffer);
fileIo.closeSync(file);
this.imgUrl[n] = 'file://' + imgPath;
}
该功能实现了完整的文件操作流程:通过getContext().tempDir
获取应用临时目录,使用fileIo.openSync
创建新文件,通过buffer.from
将Base64数据转换为二进制缓冲区,最后通过fileIo.writeSync
写入文件。这种实现方式符合鸿蒙OS的文件安全规范,确保数据仅在应用沙箱内可访问。
3.2 响应式状态管理
应用通过ArkTS的响应式状态装饰器实现数据与UI的自动同步:
// 响应式状态定义
@State message: string = '汉字书写练习';
@State modeIndex: number = 0; // 0:毛笔 1:橡皮擦
@State selectedWidth: number = 25; // 笔刷粗细
@State wordOpacity: Array<number> = [1, 0, 0, 0, 0]; // 汉字选择透明度
当wordOpacity
数组中某个元素的值从0变为1时,对应的汉字选择项会通过opacity
样式属性自动高亮,无需手动操作DOM。这种声明式的状态管理方式大幅简化了代码逻辑,提升了开发效率。
四、横屏布局优化与交互设计
4.1 界面元素的横屏适配
为适应横屏显示,应用对各界面元素的尺寸与比例进行了针对性调整:
// 画布横屏适配
Canvas(this.context)
.aspectRatio(1) // 正方形画布,充分利用横屏宽度
.height('90%') // 占据90%的高度空间
.borderRadius(15)
// 底部导航区布局
Row() {
Image($r('app.media.last'))
.height('100%')
.width('8%') // 按钮宽度占比8%
.borderRadius(5);
// 汉字选择区省略...
}
通过aspectRatio(1)
将画布设置为正方形,使其在横屏中呈现为宽屏正方形,既保证了书写空间,又符合汉字方块字的视觉特性。底部导航区的按钮宽度设置为8%,确保五个汉字选择项在横屏中均匀分布。
4.2 交互流程优化
应用实现了流畅的汉字练习交互流程:
-
通过底部汉字选择区切换练习内容
-
使用"清除"按钮重置当前汉字书写
-
通过"上一个/下一个"按钮切换练习顺序
-
完成所有汉字后跳转至作品预览页
// 下一个汉字切换逻辑
onClick(() => {
// 保存当前书写内容
this.imgUrl[this.n] = this.context.toDataURL();
this.savePicture(this.imgUrl[this.n], this.n);// 清空画布
this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
this.drawGuideLines(this.context, 20);// 切换至下一个汉字或跳转至完成页
if (this.n === 4) {
this.pageInfos.pushPathByName('Finish', this.imgUrl);
} else {
this.nextImg = this.n + 1 === 4 ? 'app.media.finish' : 'app.media.next';
this.n++;
this.wordOpacity[this.n] = 1;
this.wordOpacity[this.n-1] = 0;
}
});
该逻辑在切换汉字时会先保存当前书写内容,再清空画布并加载新汉字,确保用户练习成果不丢失。完成所有汉字练习后,通过pageInfos.pushPathByName
跳转到完成页,展示所有保存的作品。
五、技术亮点
5.1 核心技术亮点
- 物理引擎模拟毛笔效果:通过距离算法动态调整笔刷粗细,还原毛笔书写的压力感
- 数学几何绘制米字格:基于勾股定理计算顶点坐标,实现精准的辅助线布局
- 响应式状态管理:利用ArkTS的@State装饰器,实现数据与UI的双向绑定
- 沙箱文件操作:遵循鸿蒙OS安全规范,确保用户数据仅在应用内可访问
六、附:代码
import { fileIo } from '@kit.CoreFileKit';
import { buffer } from '@kit.ArkTS';
import { window } from '@kit.ArkUI';
@Entry
@Component
struct CalligraphyPractice {
@Provide('pageInfos') pageInfos: NavPathStack = new NavPathStack();
@State message: string = '汉字书写练习';
@State modeValue: string = '毛笔'; // 当前工具:毛笔 or 橡皮擦
@State modeIndex: number = 0; // 工具下标
@State selectedWidth: number = 25; // 笔刷粗细
@State selectedColor: string = '#8B0000'; // 毛笔颜色(暗红)
@State imgUrl: Array<string> = []; // 保存图像URL
@State wordOpacity: Array<number> = [1, 0, 0, 0, 0]; // 汉字选择透明度
@State clearOpacity: number = 0.5;
@State n: number = 0; // 当前汉字索引
@State nextImg: string = 'app.media.next';
@State imgHeight: Array<string> = ['70%', '100%'];
@State showGuide: boolean = true; // 是否显示笔画引导
@State imgOpacity: number = 0.5;
// 画布参数
private canvasWidth: number = 0;
private canvasHeight: number = 0;
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D();
// 练习的汉字列表
private words: Array<string> = ['永', '天', '地', '人', '和'];
// 页面初始化(横屏显示)
aboutToAppear(): void {
// 设置当前app以横屏方式显示
window.getLastWindow(getContext()).then((windowClass) => {
windowClass.setPreferredOrientation(window.Orientation.LANDSCAPE) // 设置为横屏
})
}
// 获取米字格顶点坐标
getPoints = (r: number, l: number) => {
let points: number[][] = [[], [], [], []];
points[0] = [r - Math.sqrt(r * r / 2), r - Math.sqrt(r * r / 2)];
points[1] = [l - points[0][0], points[0][1]];
points[2] = [points[1][1], points[1][0]];
points[3] = [l - points[0][0], l - points[0][1]];
return points;
}
// 构建路由表
@Builder
PagesMap(name: string) {
if (name === 'Finish') {
FinishPage()
}
}
// 绘制米字格辅助线
drawGuideLines = (ctx: CanvasRenderingContext2D, r: number) => {
const width = ctx.width;
const height = ctx.height;
let points = this.getPoints(r, width);
// 对角线1
let n = 100;
let step = width / n;
let start = points[0];
ctx.beginPath();
ctx.moveTo(start[0], start[1]);
ctx.lineTo(width, height);
ctx.strokeStyle = '#D2B48C';
ctx.lineWidth = 1;
ctx.stroke();
// 对角线2
start = points[1];
ctx.beginPath();
ctx.moveTo(start[0], start[1]);
ctx.lineTo(0, height);
ctx.stroke();
// 竖中线
ctx.beginPath();
ctx.moveTo(width / 2, 0);
ctx.lineTo(width / 2, height);
ctx.stroke();
// 横中线
ctx.beginPath();
ctx.moveTo(0, height / 2);
ctx.lineTo(width, height / 2);
ctx.stroke();
}
// 保存图片到沙箱
savePicture(img: string, n: number) {
const imgPath = getContext().tempDir + '/' + Date.now() + '.jpeg';
const file = fileIo.openSync(imgPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE);
const base64Image = img.split(';base64,').pop();
const imgBuffer = buffer.from(base64Image, 'base64');
fileIo.writeSync(file.fd, imgBuffer.buffer);
fileIo.closeSync(file);
this.imgUrl[n] = 'file://' + imgPath;
}
build() {
Navigation(this.pageInfos) {
Column() {
// 顶部标题和熊猫图标
Row() {
Image($r('app.media.panda'))
.margin({ top: 20 })
.aspectRatio(1)
.height('80%')
.margin({ top: 10, left: 30 });
// 书写区域
Stack() {
// 汉字提示
Text(this.words[this.n])
.fontSize(200)
.fontFamily('STKaiti') // 楷体字体
.opacity(this.showGuide ? 0.1 : 0);
// 画布
Canvas(this.context)
.aspectRatio(1)
.height('90%')
.backgroundColor('#FFF8DC') // 米黄色背景
.borderRadius(15)
.opacity(0.9)
.onReady(() => {
this.drawGuideLines(this.context, 20);
})
.onAreaChange((oldVal, newVal) => {
this.canvasWidth = newVal.width as number;
this.canvasHeight = newVal.height as number;
})
.onTouch((event) => {
const touch: TouchObject = event.touches[0];
switch (event.type) {
case TouchType.Down:
this.context.beginPath();
this.context.moveTo(touch.x, touch.y);
this.clearOpacity = 1;
break;
case TouchType.Move:
// 毛笔效果:移动时线条粗细变化
const distance = Math.sqrt(
(touch.x - this.canvasWidth/2) * (touch.x - this.canvasWidth/2) +
(touch.y - this.canvasHeight/2) * (touch.y - this.canvasHeight/2)
);
const brushWidth = this.selectedWidth * (1 - distance / Math.max(this.canvasWidth, this.canvasHeight) * 0.3);
this.context.lineWidth = brushWidth > 5 ? brushWidth : 5;
this.context.strokeStyle = this.modeIndex === 0 ? this.selectedColor : 'white';
this.context.lineTo(touch.x, touch.y);
this.context.stroke();
break;
case TouchType.Up:
this.context.closePath();
break;
}
});
}
.margin({ left: 20, top: 5 });
// 清除按钮
Column() {
Button('清除')
.opacity(this.clearOpacity)
.type(ButtonType.ROUNDED_RECTANGLE)
.fontColor('#8B0000')
.fontSize(28)
.backgroundColor('#FFEBCD')
.width('15%')
.height('18%')
.margin({ top: 20, left: 10 })
.onClick(() => {
this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
this.drawGuideLines(this.context, 20);
this.clearOpacity = 0.5;
});
}
.height('100%');
}
.width('100%')
.height('80%');
// 底部导航区
Row() {
// 上一个按钮
Image($r('app.media.last'))
.onClick(() => {
if (this.n > 0) {
this.n--;
this.wordOpacity[this.n] = 1;
this.wordOpacity[this.n+1] = 0;
this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
this.drawGuideLines(this.context, 20);
}
if (this.n < 4) {
this.nextImg = 'app.media.next';
}
if (this.n === 0) {
// 当切换到第一个汉字时,设置底部导航栏图片透明度为0.5
this.imgOpacity = 0.5;
}
})
.opacity(this.imgOpacity)
.backgroundColor('#FFEBCD')
.margin({ left: 20 })
.height('100%')
.borderRadius(5)
.borderWidth(8)
.borderColor('#FFEBCD');
// 汉字选择区
Row({ space: 10 }) {
Stack() {
BottomText({ imgH: this.imgHeight[0], wds: this.words[0] })
BottomText({ imgH: this.imgHeight[1], wds: this.words[0], wdSize: 45 })
.opacity(this.wordOpacity[0])
}
Stack() {
BottomText({ imgH: this.imgHeight[0], wds: this.words[1] })
BottomText({ imgH: this.imgHeight[1], wds: this.words[1], wdSize: 45 })
.opacity(this.wordOpacity[1])
}
Stack() {
BottomText({ imgH: this.imgHeight[0], wds: this.words[2] })
BottomText({ imgH: this.imgHeight[1], wds: this.words[2], wdSize: 45 })
.opacity(this.wordOpacity[2])
}
Stack() {
BottomText({ imgH: this.imgHeight[0], wds: this.words[3] })
BottomText({ imgH: this.imgHeight[1], wds: this.words[3], wdSize: 45 })
.opacity(this.wordOpacity[3])
}
Stack() {
BottomText({ imgH: this.imgHeight[0], wds: this.words[4] })
BottomText({ imgH: this.imgHeight[1], wds: this.words[4], wdSize: 45 })
.opacity(this.wordOpacity[4])
}
}
.margin({ left: 10 });
// 下一个/完成按钮
Image($r(this.nextImg))
.borderWidth(8)
.borderColor('#FFEBCD')
.backgroundColor('#FFEBCD')
.height('100%')
.borderRadius(5)
.onClick(() => {
// 保存当前书写内容
this.imgUrl[this.n] = this.context.toDataURL();
this.savePicture(this.imgUrl[this.n], this.n);
// 清空画布并准备下一个汉字
this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
this.drawGuideLines(this.context, 20);
if (this.n === 4) {
// 所有汉字完成,跳转到结果页
this.pageInfos.pushPathByName('Finish', this.imgUrl);
} else {
// 切换到下一个汉字
this.nextImg = this.n + 1 === 4 ? 'app.media.finish' : 'app.media.next';
this.n++;
this.wordOpacity[this.n] = 1;
this.wordOpacity[this.n-1] = 0;
if (this.n > 0) {
this.imgOpacity = 1;
}
}
console.log('n: ' + this.n);
})
.margin({ left: 250 });
}
.width('100%')
.height('18%')
.backgroundColor('#FFEBCD');
}
}
.width('100%')
.mode(NavigationMode.Stack)
.navDestination(this.PagesMap)
.hideTitleBar(true)
.hideToolBar(true)
.backgroundColor('#8B4513'); // 棕色背景
}
}
// 底部汉字选择组件
@Component
export struct BottomText {
public imgH: string = '';
public wds: string = '';
public wdSize: number = 30;
build() {
Stack() {
Text(this.wds)
.height(this.imgH)
.fontSize(this.wdSize)
.fontFamily('STKaiti');
Image($r('app.media.mi'))
.height(this.imgH)
.opacity(0.3);
}
.aspectRatio(1)
.borderRadius(5)
.backgroundColor('#FFEBCD');
}
}
// 完成页组件
@Component
export struct FinishPage {
@Consume('pageInfos') pageInfos: NavPathStack;
private imgUrl: Array<string> = [];
@Builder
ImageItem(url: string) {
Image(url)
.backgroundColor('#FFF8DC')
.borderRadius(10)
.height('30%')
.width('18%');
}
build() {
NavDestination() {
Column() {
Image($r("app.media.panda"))
.height('25%')
.aspectRatio(1)
.margin({ top: 10 });
Text('你的书法作品:')
.fontSize(30)
.fontWeight(FontWeight.Bold)
.fontFamily('STKaiti')
.margin({ top: 10, bottom: 15 });
Row({ space: 10 }) {
this.ImageItem(this.imgUrl[0])
this.ImageItem(this.imgUrl[1])
this.ImageItem(this.imgUrl[2])
this.ImageItem(this.imgUrl[3])
this.ImageItem(this.imgUrl[4])
}
.margin({ top: 5 });
Button('重新练习')
.type(ButtonType.ROUNDED_RECTANGLE)
.fontColor('#8B0000')
.fontSize(30)
.backgroundColor('#FFF8DC')
.height('15%')
.width('30%')
.margin({ top: 30 })
.onClick(() => {
// 清空数据并返回首页
this.imgUrl = [];
this.pageInfos.pop()
});
}
.height('100%')
.width('100%')
.backgroundColor('#8B4513');
}
.onReady((context: NavDestinationContext) => {
this.pageInfos = context.pathStack;
this.imgUrl = context.pathInfo.param as Array<string>;
})
.hideTitleBar(true)
.hideToolBar(true);
}
}