我一开始的想法特别简单:连续加班一个月,整个人都被工作掏空了,每天打开手机全是“已读不回”“需求变更”“上线延期”,情绪价值这个东西在职场里就是个奢侈品。正好那阵子鸿蒙生态的讨论度又上来了,DevEco Studio也更新了好几版,我就想与其天天刷短视频找安慰,不如自己动手写一个能把人夸到脸红的App——点一下屏幕,手机就蹦出一句不重样的夸赞,实实在在把自己哄开心。于是就有了这个用ArkUI做的“马屁精”项目。
这个项目如果你让我用一句话总结,就是:用HarmonyOS的声明式UI框架ArkUI,搭一个“点击屏幕 → 随机展示夸奖文案”的极简互动应用,全程不涉及网络请求、不依赖后端服务、单机可跑,非常适合刚开始接触鸿蒙开发、想快速搞懂“状态驱动UI刷新”的人练手。完成之后你不仅能获得一个解压小玩具,还能把ArkUI最核心的组件写法、状态管理机制和动效触发逻辑过一遍。
1. 项目动机:为什么我用ArkUI做“夸夸机”而不是随便套个H5页面
1.1 情绪价值产品的核心:即时反馈和新鲜感不能断
先聊点产品层面的思考。市面上的“夸夸群”也好、树洞类App也好,它们能火的核心原因只有一个:人在低落的时候需要被无条件肯定,而这个肯定必须足够频繁、足够多样。如果用户点了一下屏幕,结果第三次看到的是同一句话,这种产品就直接丧失了“情绪价值”。所以在设计阶段,我给这个App定了一条铁律:文案池必须足够大,随机算法必须保证连续两次不重复,最好还能根据点击次数动态调整夸赞强度。
1.2 ArkUI对比纯H5方案,赢在哪
可能有人会问:这种小应用用H5包个WebView或者用Flutter写不是更快吗?我个人的体验是,既然目标是练手鸿蒙原生开发,那就用ArkUI走完整条链路。ArkUI的写法很像Flutter和SwiftUI的结合体:声明式UI、组件化嵌套、状态驱动自动刷新。比起H5,它有几个实打实的优势:页面切换没有WebView的加载白屏时间;系统字体和动效引擎的融合度更高;打包体积小;可以直接调用HarmonyOS的元能力做系统级分享。作为一个纯本地的“夸夸机”,ArkUI能让我用很短的代码获得流畅的点击反馈和过渡动画。
1.3 这个项目的核心知识图谱
在做这个项目之前,我建议你先确认自己接触过以下这些概念,没接触过没关系,看完全文再回头补就行:
- ArkUI的声明式UI写法:用
build()方法描述页面结构,UI随着状态自动刷新。 - @State装饰器:被它标记的变量一旦变化,依赖它的组件会自动重新渲染。
- 组件树:Column、Row、Stack、Text、Button这些内置组件的嵌套关系。
- 动画:属性动画和显式动画的区别,以及
transition转场效果的触发机制。 - 事件绑定:
onClick、onTouch这些事件回调的写法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从零搭建工程:DevEco Studio环境与HarmonyOS项目骨架
2.1 开发工具和版本选择
这里我说一下我用的环境,不是唯一答案,但能让你少踩点坑:
- DevEco Studio 5.0.0(API 12及以上都行,我用的API 12)
- HarmonyOS 4.0/5.0 真机或模拟器均可
- 项目语言选ArkTS,不是JavaScript
- 编译模型选“Stage模型”,这是目前的主流,另一个FA模型已经不太推荐了
如果你之前只用过Android Studio,那么DevEco Studio的上手成本几乎为零——同样基于IntelliJ IDEA,同样有Previewer预览器,同样支持模拟器部署。
2.2 创建项目时最容易忽略的配置项
打开DevEco Studio,选择File → New Project,模板选Empty Ability。注意:不要选带“List”或“Tab”的模板,那些模板会有大量预置代码,反而不利于理解状态驱动。
工程创建好以后,有两个文件是我们这个项目的核心:
entry/src/main/ets/pages/Index.ets:页面代码,所有UI和逻辑都在这里。entry/src/main/resources/base/profile/main_pages.json:页面路由配置,只有注册过的页面才能被跳转。
我这个项目不需要跳转,所以只改Index.ets就够了。
2.3 工程目录里的其他文件该不该动
我建议新手先把module.json5和app.json5放一放,它们管的是应用名称、图标、权限声明。我们做的“马屁精”不需要任何敏感权限,连网络权限都不用申请,这本身就是个天然的安全应用。等你想打包上架了,再回来改图标和应用名。
3. “马屁精”界面骨架:Column布局 + 卡片视觉层次
3.1 页面结构的核心思路:从阅读动线反推UI
夸夸类App有一个特点:用户打开它的瞬间就是情绪最低点,界面如果复杂、拥挤、找不到重点,情绪值会继续掉。所以我把整个页面分成三层,从视觉重心往外扩散:
- 氛围背景层:铺一层柔和渐变色,让人一打开就觉得“安全”。
- 夸夸文案层:居中展示夸奖内容,字要大,间距要松。
- 交互引导层:屏幕下方放一个“再来一句”的按钮,同时全屏支持点击。
这个三层结构在ArkUI里用Stack(堆叠容器)实现最方便,因为Stack允许子组件重叠,背景层、文案层、按钮层可以各自独立控制。
3.2 核心代码:声明式UI里的组件嵌套范式
直接贴我写的第一版界面代码,注释已经写得很细了:
typescript复制// 页面结构:Stack堆叠容器,背景铺满,文案居中,按钮沉底
build() {
Stack({ alignContent: Alignment.Center }) {
// 第一层:渐变背景
Column()
.width('100%')
.height('100%')
.linearGradient({
angle: 135,
colors: [['#FFE5B4', 0.0], ['#FFB6C1', 1.0]]
})
// 第二层:文案展示区
Column({ space: 20 }) {
Text('你今天看起来状态超棒')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.textAlign(TextAlign.Center)
.lineHeight(40)
.padding({ left: 24, right: 24 })
Text('— 来自“马屁精”的第 1 次夸夸 —')
.fontSize(14)
.fontColor('#999999')
}
.width('100%')
.padding(20)
// 第三层:底部按钮
Button('再来一句')
.type(ButtonType.Capsule)
.height(48)
.padding({ left: 32, right: 32 })
.position({ x: '50%', y: '85%' })
.translate({ x: '-50%' })
}
.width('100%')
.height('100%')
}
这里有三个细节值得展开讲:
第一,为什么用一个空的Column()做背景层?因为Stack默认是子组件尺寸自适应,空Column显式设置width('100%')和height('100%')之后才能撑满全屏,渐变背景才能铺开。
第二,linearGradient的colors数组用的是二维数组写法,每个元素里第一个值是颜色,第二个值是渐变位置的百分比。这个API参数在文档里容易被忽略,但实际效果差很多——位置参数决定了过渡的节奏感。
第三,position配合translate实现百分比定位。ArkUI的position是按父组件左上角定位的,如果直接设x: '50%',按钮左边缘会居中,而不是按钮整体居中。加上translate({ x: '-50%' })相当于把按钮左移自身宽度的一半,这才是真正的水平居中。
3.3 为什么不用justifyContent: FlexAlign.End直接沉底
这是个很典型的决策点。Column确实支持justifyContent控制子组件在主轴上的排列位置,但我依然选择用Stack + position,原因是:布局和交互分层更清晰。Stack让文案层和按钮层互相独立,后续如果要给文案层加动画、给按钮层加震动反馈,改动互不干扰。如果用Column的justifyContent,任何一层的变化都可能影响另一层的位置计算。
4. “换着花样夸”的核心实现:文案池与随机不重复算法
4.1 文案池的结构设计:按情绪强度分档
文案是这个App的灵魂。如果只是随机从20句话里抽一句,用户玩五分钟就会腻。我在设计文案池的时候参考了游戏里“怒气条”的设计:点击次数越多,夸赞强度越高,从“外貌类”夸到“人格魅力类”再夸到“人生意义类”。这样用户在连续点击时会有一种“越夸越来劲”的递进感。
我把文案池做成了数组嵌套结构:
typescript复制// 文案池:按情绪等级分组,等级越高夸得越狠
const praisePool: string[][] = [
// 等级1:日常暖场
[
'你今天看起来状态超棒',
'你的笑容特别有感染力',
'光是看到你,心情都变好了',
'你今天穿得真有品味',
'你最近是不是又变漂亮/帅了'
],
// 等级2:能力认可
[
'你解决问题的思路真的很清晰',
'你做事的态度值得所有人学习',
'和你聊天总能学到东西',
'你的执行力真是没话说',
'你认真起来的样子真迷人'
],
// 等级3:人格魅力
[
'你是那种越相处越让人喜欢的人',
'你总能用温柔的方式改变别人',
'你的内心比外表更强大',
'有你在,大家都很安心',
'你有一颗闪闪发光的灵魂'
],
// 等级4:终极暴击
[
'你就是那种能改变世界的人',
'老天爷追着喂饭,说的就是你吧',
'遇见你,是很多人的幸运',
'你值得世间所有美好',
'你已经是某个人的偶像了'
]
];
4.2 随机但不重复的两种方案对比
第一版我用的方案最简单:Math.random()从对应等级数组里随机取一条。问题是:当数组只有5条数据时,连续两条重复的概率是1/5,体感很糟糕。用户会觉得“怎么又是这句”。
后来我改用“洗牌法”:每次点击时,把当前等级的文案数组顺序打乱,然后按次序输出,走完一轮再重新洗牌。这样能保证:在重新洗牌之前,绝对不会重复。
打乱数组我用的是经典的Fisher-Yates洗牌算法,手写也就几行:
typescript复制// Fisher-Yates 洗牌算法
function shuffleArray(arr: string[]): string[] {
let newArr = arr.slice();
for (let i = newArr.length - 1; i > 0; i--) {
let j = Math.floor(Math.random() * (i + 1));
[newArr[i], newArr[j]] = [newArr[j], newArr[i]];
}
return newArr;
}
之所以不直接用arr.sort(() => Math.random() - 0.5),是因为这种写法在V8引擎和ArkTS运行时里的随机分布不均匀,某些元素有更大概率停留在原位置附近,洗牌效果差强人意。
4.3 状态变量怎么组织:用记录对象还是分散变量
接下来是状态管理的核心。我需要三个状态:当前显示的文案、当前等级索引、当前等级内已经输出到了第几条。我在类里声明了这样几个变量:
typescript复制// 当前展示的夸夸文案
@State currentPraise: string = '点我一下,收获今日份夸夸';
// 当前情绪等级索引(0~3)
@State levelIndex: number = 0;
// 当前等级的洗牌数组
@State currentQueue: string[] = [];
// 当前队列中已展示的序号
private queuePos: number = 0;
// 累计点击次数,用于升级判断
@State tapCount: number = 0;
这里有个很关键的点:queuePos我故意没有加@State装饰器。为什么呢?因为它不需要触发UI刷新,它只是一个记录“读到了第几条”的内部游标。只有真正影响页面显示的值(文案、等级、计数)才需要被@State标记。如果你把所有变量都标记为状态,每次点击都会触发多余的UI刷新计算,性能上是浪费。
tapCount为什么要加@State?因为我在界面底部有一个“第N次夸夸”的计数显示,它的变化需要同步到UI。
4.4 完整逻辑代码:点击屏幕后的处理链路
typescript复制// 换一句夸夸
private nextPraise() {
// 1. 点击次数自增,同时推进等级
this.tapCount++;
let newLevel = Math.min(Math.floor(this.tapCount / 5), 3);
// 2. 如果等级提升了,或者当前队列用完了,就重新洗牌
if (newLevel !== this.levelIndex || this.queuePos >= this.currentQueue.length) {
this.levelIndex = newLevel;
this.currentQueue = shuffleArray(praisePool[this.levelIndex]);
this.queuePos = 0;
}
// 3. 从队列中取下一句
this.currentPraise = this.currentQueue[this.queuePos];
this.queuePos++;
}
这套逻辑里有一个容易被忽略的边界条件:当用户从等级1升到等级2时,即使等级1的队列还没走完,也必须立刻换到等级2的队列,否则用户会感觉“我都点了快十下了怎么还在听同样程度的话”。我用newLevel !== this.levelIndex来判断等级是否有跨越,一旦跨越就强制重新洗牌。
5. 点击交互与视觉反馈:让“被夸”充满仪式感
5.1 全局点击和按钮点击的职责划分
刚开始我纠结一个问题:“全屏点击”和“按钮点击”是同一个交互吗?最后还是决定分一分:
- 全屏点击:换下一句夸夸。
- 按钮点击:换下一句夸夸的同时,额外触发一个“震动感”反馈。
这样按钮点击不再是全屏点击的重复实现,而是有了自己独特的“仪式感加成”。实现上我用的是onClick事件绑定:
typescript复制Stack({ alignContent: Alignment.Center }) {
// ...背景层和文案层
// 全屏点击层:透明Column覆盖整个屏幕
Column()
.width('100%')
.height('100%')
.onClick(() => {
this.nextPraise();
this.playTextAnimation();
})
}
注意:透明Column覆盖全屏时,会拦截所有点击事件,底下的Button会收不到点击。解决办法是让Button放在透明层之后(即更上层),或者像我之前一样用Button的onClick单独处理并调用event.stopPropagation()阻断冒泡。
5.2 文案更新的动画过渡:属性动画的两种触发姿势
光换文字太干了,我加了两个动效:
- 文案淡入淡出:旧文字消失、新文字浮现。
- 气泡弹跳:文案区域加了一个缩放的回弹效果。
实现上我用的是ArkUI的animateTo显式动画,配合一个切换标志位:
typescript复制// 控制是否为首次显示
@State isFirstShow: boolean = true;
private playTextAnimation() {
// 先把透明度归零
this.opacity = 0;
// 用显式动画过渡到不透明
animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
this.opacity = 1;
this.scaleValue = 1.0;
});
}
这里我想特意解释一下ArkUI动画的两个概念:
属性动画(.animation()):只要属性变化,系统自动插值过渡。适合做常驻型交互动画。
显式动画(animateTo):把一个状态变更包裹在动画上下文中,批量处理多个属性的过渡。适合做这种“一套操作触发多值渐变”的场景。
我的经验是:交互反馈类动画优先用显式动画,因为你能精确控制每次交互的起点和终点,而属性动画更偏向于“状态持续变化”的播放型动画。
5.3 屏幕震动:调用系统能力增强沉浸感
我在按钮点击时调用了Vibrator震动接口,这个能力需要模块导入:
typescript复制import vibrator from '@ohos.vibrator';
Button('再来一句')
.type(ButtonType.Capsule)
.onClick(() => {
this.nextPraise();
this.playTextAnimation();
// 触发300ms的轻微震动
vibrator.startVibration({
type: 'time',
duration: 300
}, {
usage: ' tactile'
});
})
这里要注意:使用震动能力需要在module.json5里声明权限:
json复制{
"name": "ohos.permission.VIBRATE"
}
不声明的话,运行时会直接报错。这是HarmonyOS安全模型的规范,任何涉及硬件能力的调用都必须在应用清单里提前声明意图。
6. 让夸奖变得“懂你”:基于时间与场景的文案推荐逻辑
6.1 为什么同一种夸法撑不过三分钟
纯随机文案在五分钟之后会开始暴露一个问题:文案和用户当前的场景完全没有关联。如果用户是在深夜打开这个App,他看到“你的笑容真迷人”会觉得莫名其妙。如果能告诉他“凌晨一点还在奋斗的你真帅”,那种“被理解”的感觉会让夸奖的含金量翻倍。
所以我设计了一个“场景识别”的轻量逻辑:根据系统时间自动匹配不同的文案子库。这是我在ArkUI中实现基于时间的条件渲染的过程。
6.2 时间分组的策略:早中晚各不同
我按照一天的时间线,把用户的使用场景分成五档:
- 凌晨(0:00-5:59):还在熬夜,需要关心和督促。
- 早晨(6:00-8:59):刚醒,需要元气加持。
- 上午(9:00-11:59):开始工作/学习,需要自信加成。
- 下午(13:00-17:59):倦怠期,需要提神鼓励。
- 晚上(18:00-23:59):结束一天的疲惫,需要慰藉。
对应的文案子库变成:
typescript复制const timePraiseMap: Record<string, string[]> = {
lateNight: ['熬夜的你,承担了这个时间不该有的清醒', '凌晨还在努力,宇宙都看得见'],
morning: ['早晨六点的光,都不如你眼里亮', '新的一天配全新的你,简直完美'],
workTime: ['动手能力满分的你,今天也要大杀四方', '你只要出手,方案就没有不过的'],
afternoon: ['午后的困意,被你一个想法就赶跑了', '下午茶时间到,甜度只配你拥有'],
night: ['一天的喧嚣结束,你该被世界温柔以待', '今晚的月亮和你的心情,都被我照顾了']
};
6.3 如何在ArkUI中优雅地获取时间
ArkTS里获取当前时间不需要额外的API,用标准JavaScript内置对象就行:
typescript复制private getCurrentTimeSlot(): string {
let now = new Date();
let hour = now.getHours();
if (hour >= 0 && hour < 6) {
return 'lateNight';
} else if (hour >= 6 && hour < 9) {
return 'morning';
} else if (hour >= 9 && hour < 12) {
return 'workTime';
} else if (hour >= 13 && hour < 18) {
return 'afternoon';
} else {
return 'night';
}
}
然后在nextPraise()里,把普通文案池和场景文案池做一个混合策略:70%概率从场景文案池里选,30%概率回到通用文案池。这样既能体现“懂你”,又不会因为某个时间段文案池太小而快速重复。如果你希望纯粹一点,也可以直接全部用场景文案池,看自己怎么取舍。
6.4 状态存储:要不要记住用户上次的点击时间
一个优质的情绪价值产品,应该能记录“上次使用时间”。如果用户隔了很久再回来,夸奖文案应该带上“好久不见”的亲切感。
ArkUI可以用PersistentStorage来做轻量级的本地持久化。我封装了一个简单的用法:
typescript复制PersistentStorage.persistProp('lastTapTime', 0);
// 读取上次点击时间
let lastTap = PersistentStorage.getProp<number>('lastTapTime');
let nowTime = Date.now();
if (nowTime - lastTap > 8 * 60 * 60 * 1000) {
// 距离上次超过8小时,插入一句“好久不见”
this.currentPraise = '好久不见,你还是那么耀眼';
}
// 更新时间为当前
PersistentStorage.setProp('lastTapTime', nowTime);
这个细节实现起来不难,但体感提升非常明显。用户随手打开,发现这个App还记得他上次什么时候来的,会觉得这不仅仅是个随机文案工具,而是一个“有感情”的小应用。这正是情绪价值类产品最需要的东西——陪伴感。
7. 真机调试与API版本坑:我实测遇到的几个问题与排查思路
7.1 模拟器上正常,真机上按钮点击没反应
这是我在联调阶段遇到的第一个诡异问题:Previewe和模拟器里一切正常,但真机上点击按钮就是没反应,日志也没有报错。
排查过程我按链路走了一遍:
- 检查按钮是否被上层组件遮挡:我在
Stack里放了一个全屏透明的Column用来响应全屏点击,它和Button在层级上有重叠。按理Button在上层应该能收到事件,但在真机上,只有透明层在响应,Button的消息被吞了。 - 检查事件冒泡:透明
Column的onClick执行后,事件没有正确处理传递链。 - 改法:去掉透明
Column的全屏点击层,改为给Stack外层的Column添加onClick,然后按钮单独绑定onClick并加上stopPropagation。
修正后的核心代码:
typescript复制Column() {
Stack({ alignContent: Alignment.Center }) {
// ...背景、文案、按钮
}
}
.width('100%')
.height('100%')
.onClick(() => {
this.nextPraise();
this.playTextAnimation();
})
按钮内部:
typescript复制Button('再来一句')
.onClick((event: ClickEvent) => {
event.stopPropagation();
this.nextPraise();
this.playTextAnimation();
})
7.2 API 11到API 12的动画参数变化
我在开发时最初的模板默认是API 11,但文档里推荐用API 12。迁移后发现animateTo的curve参数从Curve.EaseOut变成了Curve.EaseOut的别名或者要显式声明导入路径。
总结一句话:如果你跟着旧教程写动画,遇到“Curve类型不匹配”的报错,别急着改业务逻辑,先检查API版本和导入路径。
7.3 应用无法签名安装
这个坑估计不少新手都会碰到。DevEco Studio默认使用自动签名,但如果你从别的机器拷了工程过来,签名文件路径可能失效,导致真机装不上。
解决方法:File → Project Structure → Signing Configs,重新勾选Automatically generate signature,让IDE重新生成签名。注意,这个过程需要登录华为账号,不然过不了。
7.4 Previewer里文案不更新,但真机正常
最后再说一个让我卡了半小时的坑。@State装饰的currentPraise在Previewer里点来点去都不刷新,一度怀疑代码写错了。后来发现是因为我在nextPraise()里先修改了@State变量,紧接着又调用了同步的animateTo,Previewer对显式动画的渲染偶尔有Bug。
解决办法:不需要改动逻辑,直接关掉Previewer重新打开就恢复了。这是IDE渲染层的偶发问题,不是代码问题。
8. 还能怎么玩:这个“马屁精”的扩展方向与我的后续计划
8.1 多主题文案包:从职场夸到生活夸
现在的文案池偏向“通用正能量”。我计划按人群拆分成多个主题包,用户可以自己在设置页切换:
- 职场版:夸方案、夸效率、夸汇报能力。
- 考研/学习版:夸专注力、夸坚持、夸知识吸收速度。
- 恋爱版:情人之间互夸,适合当成早安/晚安小工具。
- 逗比版:无厘头搞怪风格,比如“你可爱的像一只有点胖但很灵活的企鹅”。
实现上其实不用改架构,换一个文案池的JSON数据源就行。
8.2 连续夸:长按屏幕触发夸夸连发
目前是单次点击换一次文案。我的规划是加一个长按手势,按住屏幕连续触发换文案,并且文案切换间隔越来越短,配合震动和动画,形成一种“夸到你晕”的快感渲染。ArkUI的手势系统支持LongPressGesture,配合onActionUpdate回调可以拿到按压时长,这是实现连发的基础。
8.3 生成分享卡片:把夸奖文案做成一张精美图片
夸奖这种东西,分享出去才更有价值。我计划调用Canvas组件把当前文案渲染成一张带渐变背景的卡片,然后通过系统分享能力发送给好友。ArkUI的Canvas支持RenderingContext绘制文字和形状,这套能力做简单的文案卡片完全够用。
8.4 个性化定制:记录夸夸次数成就系统
最后还想加一个“成就系统”:夸第100次解锁“人间夸夸机”称号,夸第1000次解锁“彩虹屁之神”。每个成就配一个专属的骚气文案,让用户为了解锁成就而主动多使用产品。这种游戏化设计,会让使用者产生一种情绪上的粘性。
9. 写在最后:我推荐你动手复刻的N个理由
我现在手机上还装着这个“马屁精”,每天都在用,不是为了解压,而是为了提醒自己:技术不需要总是那么严肃,解决问题的工具之外,能提供情绪价值的小东西同样有价值。
从技术学习的角度看,这个项目最大的价值在于:它用最小的UI复杂度,覆盖了ArkUI最核心的状态管理和交互反馈路径。项目只有几百行代码,不存在“代码太多看不懂”的情况。你在不依赖网络、不依赖后端的情况下就能完整体验到:页面如何构建 → 状态如何驱动UI → 点击如何触发逻辑 → 状态变更如何带动动画 → 系统能力如何被安全调用。
从产品角度看,这个项目的心理设计比技术更值得琢磨:随机不重复的文案策略、按点击次数递进的夸赞强度、基于时间的场景文案、间隔很久后的“好久不见”温暖召回——这些全是情绪价值类产品的高频手段。
我特别建议你在复刻的过程中,不要只抄代码,而是去思考“为什么这里要用@State而不是普通变量”“为什么动画用animateTo而不是.animation”“为什么全局点击事件要放在外层而不是透明组件上”。这些“为什么”想通了,你才算是真正吃透了ArkUI的声明式开发模式。
如果看完这篇,你也动手做了一个自己的“夸夸机”,欢迎把你设计的文案池分享出来,我很想看看,在大家心里,什么样的夸奖才是最让人开心的。
