1. 项目缘起:为什么想做一款“情绪价值”类型的鸿蒙应用
“转转乐”这个名字听起来很轻松,实际上它是我在鸿蒙原生生态里做的一次完整实践。起初我给自己定了一个小目标:不依赖任何第三方框架,纯粹用 ArkTS 和 ArkUI 从零搭一个能真正跑在手机上的应用,并且要让它在体验上有“情绪价值”——不是工具型的效率应用,而是打开就想转一下、转完会心一笑的那种小东西。
这类应用在鸿蒙生态里其实挺稀缺的。大部分开发者都在做工具、购物、内容类应用,轻交互、重情感反馈的应用反而少。但恰恰是这类应用,对动画流畅度、反馈手感、资源占用、状态管理的考验一点不比大应用少。转转乐正好可以作为研究鸿蒙声明式 UI 能力的一个理想载体。
适合谁来参考这篇文章呢?如果你是刚接触鸿蒙开发不久,想看一个完整应用怎么从设计想法落地到代码实现;或者你已经在写鸿蒙应用,想看看别人在状态管理、画布绘制、传感器交互、分布式流转这些模块里是怎么处理细节坑的——这篇内容应该都能给你一些参考。我不打算把代码贴成一篇文档,而是把“为什么这么写”“这里不这么写会怎样”一并讲清楚。
另外说一句,这个项目本身就是“传奇开心果系列”里的一环,系列的核心风格就是一个字:好玩。技术上的每个选择都要为“玩得开心”服务,所以你会在后文看到很多地方我为了手感、为了反馈、为了转盘那一瞬间的愉悦感做了不少偏执的设计——这些偏执恰恰是应用有个性、有价值的关键。
2. 设计角度:把“情绪价值”翻译成鸿蒙交互语言
2.1 什么是情绪价值型应用的设计目标
代码还没写之前,我先想清楚了一个问题:用户打开这个应用,三秒钟之内应该感受到什么?
工具型应用的目标是“完成任务”,所以设计重点是效率、清晰、少干扰。而转转乐这类情绪型应用的目标是“唤起正面情绪”,设计重点就完全不同了。我希望用户打开应用的第一眼看到一个精心调过色的转盘,轻轻拨动一下,转盘带着柔和的物理惯性转起来,然后逐渐减速,最后停在一个彩蛋文案上——整个过程像小时候玩幸运转盘一样,有期待、有惊喜、有轻松感。
所以设计上我把三个词放在了最前面:低门槛、即时反馈、安全氛围。低门槛意味着没有任何学习成本,打开就能玩;即时反馈意味着手指一到,视觉和听觉立刻跟上,不能有延迟;安全氛围意味着文案、色彩、动效都传递善意和温暖,不搞恶搞,不制造焦虑。这三点翻译成技术需求,就是启动速度要快、手势要跟手、动画要顺滑、文案库要高质量。
这个设计思路和鸿蒙的“一次开发,多端部署”理念也正好可以结合。同一套代码,手机上是竖屏单手操作的转盘,平板上可以改成更大半径的转盘并增加更多彩蛋分类,折叠屏展开后甚至可以把历史记录和当前转盘做成左右分栏。这些多端适配不是发布时才考虑,而是从设计稿阶段就预留了布局弹性,后文我会讲具体怎么用 ArkUI 的栅格和断点能力来落地。
2.2 转盘交互的心理学基础与产品逻辑
为什么“转一下”这个动作能带来愉悦?我自己总结有三个层次。
第一个层次是掌控感。用户不是被动观看,而是亲手拨动转盘,施加的力度会直接影响转动的幅度和时长。这种“我决定了结果”的掌控感会让人产生轻度投入。
第二个层次是随机性带来的多巴胺反馈。转盘停在哪个区域是随机的,但用户会觉得“这是我自己转出来的结果”,于是对结果更有认同感。这和开盲盒的逻辑类似,随机性 + 主动参与 = 惊喜感翻倍。
第三个层次是文案共鸣。转盘结果不是冷冰冰的“恭喜你获得1积分”,而是一句有温度的话,比如“今天适合请自己喝杯奶茶”。用户会下意识把这句话和自己当下的生活状态联系起来,情绪共鸣就产生了。
这三个层次落在产品逻辑上,就是转盘区域要做得足够大、文案卡片要大而清晰、每次旋转结束要有轻微震动反馈、文案要定期更新或支持用户自定义。好的情绪价值产品不是花哨的特效堆出来的,而是每个细节都在悄悄照顾用户的感受。
2.3 信息架构:单页应用如何做到轻快
转转乐的信息架构非常克制。我把它控制在三个主页面:转盘主页面、彩蛋详情页、设置页。
转盘主页面是绝对核心,占 90% 的使用时间。页面上只有转盘、中心按钮、当前结果卡片三个视觉焦点,没有底部导航栏、没有侧边栏、没有冗余列表。彩蛋详情页是点击结果卡片后进入的轻量页面,展示当前彩蛋的完整文案、背景图和分享操作。设置页负责彩蛋库管理、音效开关、震动强度、转盘主题皮肤切换。
为什么要这么克制?因为每一层跳转都是情绪消耗。用户在情绪型应用里最忌讳“我要去哪”的思考负担。单页结构配合转盘中心的“再转一次”按钮,让整个应用的主循环变成:打开、转、看结果、再转。这也是标题里“轻快乐”三个字的产品表达。
3. 技术特色:ArkTS 声明式能力与多端适配实践
3.1 ArkTS 状态管理在转盘应用里的建模
鸿蒙应用开发里,状态管理是一个绕不开的核心话题。ArkTS 沿用了声明式 UI 的思路:界面是由状态驱动的,状态变了,界面自动刷新。这里最基本的概念是@State、@Prop、@Link,但真正用起来时需要想清楚一个问题:哪些状态应该存到应用级,哪些应该留在组件级。
转转乐里我把状态分成了三类。
第一类是全局应用状态,用@StorageLink或AppStorage来管理。比如当前选中的皮肤主题、音效开关、震动强度,这些状态在设置页修改后,主页面必须感知到并立即生效,而且应用重启后还要能恢复,所以它们要存持久化键值。
第二类是页面级状态,用@State管理。比如转盘当前旋转角度rotationAngle、当前转速currentVelocity、转盘是否处于转动中isSpinning。这些状态只在转盘页面使用,不涉及跨页面同步,放在@State里最合理。
第三类是组件内部状态,用普通局部变量或@State私有属性管理。比如结果卡片展开动画的进度值,这类状态生命周期短,不需要被外部访问。
状态管理的粒度直接决定可维护性。我见过不少初学者把几十个变量全部塞进@State,结果每次改动都会触发大范围 UI 刷新,性能问题随之而来。正确做法是尽量缩小状态的作用域,能用局部变量就不用全局状态,避免冗余刷新。
3.2 为什么选择 Canvas 绘制转盘而不是图片资源
转盘主题皮肤是动态可切换的,如果为每套皮肤准备一套静态图片,维护成本会很高,而且用户自定义彩蛋时,文案数量变了,图片上的文字就得重新切图,这在工程上完全不可持续。所以从第一版开始,转盘的主体就是我用 Canvas 画出来的。
ArkUI 的Canvas组件基于CanvasRenderingContext2D,API 风格和 Web Canvas 很接近,熟悉前端开发的同学可以无缝上手。整个转盘在绘制时分为三个图层:背景扇区层、文案文字层、装饰元素层。
背景扇区层根据彩蛋数量动态计算扇区角度,每个扇区填充不同的颜色,颜色值从主题调色板中读取。文案文字层需要处理一个细节:文字要沿着扇区的角平分线方向绘制,而且当扇区数量超过 8 个时,文字角度要统一调平,否则会出现一部分文字是正的、一部分文字是倒着的情况,非常影响阅读体验。装饰元素层在扇区边缘补充一些圆点或小星星,让转盘看起来更有质感。
用 Canvas 绘制还有一个隐形优势:绘制的是矢量图形,不依赖位图资源,不管转盘是 200 像素还是在平板上放大到 600 像素,清晰度都不会下降。另外主题切换只需要修改调色板数组,然后重新触发Canvas的RenderingContext重绘即可,整个过程不到 20 毫秒。
3.3 多端适配与响应式布局的取舍
鸿蒙生态是多设备生态,同一套代码需要跑在手机、平板、折叠屏上。我前期的经验是,多端适配不要在一开始就铺开,而是先用好 ArkUI 的响应式能力,让应用在适配上“自动做对大部分事情”。
具体来说我用了两个机制。第一个是栅格断点。HarmonyOS 提供了基于宽度的断点体系,比如sm对应手机竖屏,md对应平板竖屏或手机横屏,lg对应平板横屏。我用GridRow和GridCol布局,让转盘在主区域的栅格占比随断点变化:手机上占 12 列中的 10 列,平板上占 8 列左右,让转盘始终保持在一个舒适的可操作尺寸。
第二个是MediaQuery动态调整布局方向。折叠屏展开时,宽度超过lg断点,我判断为横屏多栏模式,布局从“转盘在上、结果在下”变成“转盘在左、结果在右”。这个改动在传统 Android 开发里需要监听配置变更并手动重建布局,但在 ArkUI 里只需要在@State中维护一个isWideMode布尔值,然后根据它决定用Row还是Column容器,声明式框架会自动完成子组件树的更新。
这样做的结果是,我在开发早期完全围绕手机竖屏调试,只要确保核心交互和数据流正确,到发布前再做一轮多端布局适配,工作量远比想象中小。
4. 实现原理与示例代码:转转乐 V1.0 的核心机制
4.1 转盘绘制核心代码逐段解析
先看最核心的转盘绘制逻辑。下面是SpinWheelCanvas的绘制代码,我把关键部分拆开讲。
// SpinWheelCanvas.ets 核心绘制逻辑 @Entry @Component struct SpinWheelCanvas { @State rotationAngle: number = 0 // 当前旋转角度 @State currentVelocity: number = 0 // 当前速度(度/秒) @State isSpinning: boolean = false // 是否正在旋转 private settings: RenderingContextSettings = new RenderingContextSettings(true) private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings) private emojiTexts: string[] = [ '嘉奖自己', '出去走走', '喝杯奶茶', '给好友发一句问候', '播放收藏歌单', '今天不做任何计划' ] private sectorColors: string[] = [ '#FFD3B6', '#B6E2D3', '#EFC7FE', '#B5DEFF', '#FFF1A6', '#FFB6C1' ] build() { Column() { Canvas(this.context) .width(340) .height(340) .rotate({ angle: this.rotationAngle }) .onTouch((event: TouchEvent) => { this.handleTouchEvent(event) }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } /** * 绘制完整转盘:扇区 + 文字 + 中心按钮背景 */ private drawWheel(): void { const ctx = this.context const radius = 150 const centerX = 170 const centerY = 170 const sectorAngle = (2 * Math.PI) / this.emojiTexts.length ctx.clearRect(0, 0, 340, 340) for (let i = 0; i < this.emojiTexts.length; i++) { const startAngle = i * sectorAngle const endAngle = (i + 1) * sectorAngle // 绘制扇区 ctx.beginPath() ctx.moveTo(centerX, centerY) ctx.arc(centerX, centerY, radius, startAngle, endAngle) ctx.closePath() ctx.fillStyle = this.sectorColors[i % this.sectorColors.length] ctx.fill() // 绘制文字 ctx.save() ctx.translate(centerX, centerY) ctx.rotate(startAngle + sectorAngle / 2) ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillStyle = '#333333' ctx.font = '16px sans-serif' ctx.fillText(this.emojiTexts[i], radius * 0.65, 0) ctx.restore() // 在每个扇区边缘绘制装饰圆点 ctx.beginPath() ctx.arc( centerX + radius * 0.95 * Math.cos(startAngle + sectorAngle / 2), centerY + radius * 0.95 * Math.sin(startAngle + sectorAngle / 2), 4, 0, 2 * Math.PI ) ctx.fillStyle = '#FFFFFF' ctx.fill() } // 绘制中心圆形 ctx.beginPath() ctx.arc(centerX, centerY, 36, 0, 2 * Math.PI) ctx.fillStyle = '#FFFFFF' ctx.fill() } }有几个细节我特别说明一下。
第一,旋转动画不是直接改Canvas内部坐标,而是通过给Canvas组件设置.rotate({ angle: this.rotationAngle })实现的。这样做的优势是绘制逻辑永远按照初始位置计算,旋转交给渲染层完成,代码清晰且不容易出坐标错乱。
第二,文字绘制时先ctx.translate(centerX, centerY)把坐标系原点移到圆心,再ctx.rotate()旋转到扇区中线方向,最后fillText在半径 0.65 倍的位置绘制。这个 0.65 倍是试出来的最优值——太靠近圆心文字会叠在一起,太靠近边缘文字会被截断。
第三,每次转动结束后需要重新计算“当前停在哪个扇区”,这个逻辑放在回调里,根据rotationAngle对360 / 扇区数取模得到。
需要特别提醒一个坑:CanvasRenderingContext2D的实例化参数里new RenderingContextSettings(true)中的true表示开启抗锯齿。曾经我图省事直接传了false,结果转盘边缘在部分设备上出现明显锯齿,怎么调都糊,最后检查发现是这个参数的问题,改回true后完美解决。
4.2 手势驱动旋转:从摆动到惯性的完整链路
转转乐最核心的交互是“拨动转盘”。这里不能简单地写一个触摸事件然后让转盘跟着手指旋转,因为真实转盘是有惯性的:手指松开后,转盘还会继续转一会儿,然后慢慢减速停下来。这种惯性感是“手感”的重要来源。
惯性逻辑用了经典的速度衰减模型。我维护一个currentVelocity变量,单位是度/秒。手指滑动过程中实时更新rotationAngle,同时估算瞬时速度;手指松开时进入惯性阶段,每一帧将速度乘以一个衰减系数(本项目中取 0.98),然后让rotationAngle累加速度值。当速度小于某个阈值(比如 1 度/秒),就认为转动结束,触发停靠动画和结果回调。
/** * 处理触摸事件:按下开始追踪,移动实时更新,抬起进入惯性 */ private handleTouchEvent(event: TouchEvent): void { if (event.type === TouchType.Down) { // 手指按下,停止任何持续中的动画,开始记录轨迹 this.stopAnimation() this.isSpinning = true this.lastTouchX = event.touches[0].x this.lastTouchY = event.touches[0].y this.lastTimestamp = Date.now() } else if (event.type === TouchType.Move) { // 计算当前触摸点与圆心的角度差 const currentX = event.touches[0].x const currentY = event.touches[0].y const currentCenterX = this.getCenterX() const currentCenterY = this.getCenterY() const preAngle = Math.atan2( this.lastTouchY - currentCenterY, this.lastTouchX - currentCenterX ) const nowAngle = Math.atan2( currentY - currentCenterY, currentX - currentCenterX ) let deltaAngle = (nowAngle - preAngle) * 180 / Math.PI // 处理跨越 360 度边界的情况 if (deltaAngle > 180) { deltaAngle -= 360 } else if (deltaAngle < -180) { deltaAngle += 360 } this.rotationAngle += deltaAngle this.currentVelocity = deltaAngle / ((Date.now() - this.lastTimestamp) / 1000) this.lastTouchX = currentX this.lastTouchY = currentY this.lastTimestamp = Date.now() } else if (event.type === TouchType.Up) { // 手指抬起,进入惯性滑动阶段 this.startInertiaAnimation() } }在handleTouchEvent的 Move 分支里,角度差计算用的是atan2。如果你直接使用event.touches[0].x和圆心的绝对坐标差去算角度,会踩一个边界问题的坑:当触摸点从 355 度附近滑向 5 度附近时,普通差值会算出 -350 度,导致转盘疯狂反转一下。我在代码里加了一个判断:差值大于 180 度时减 360,小于 -180 度时加 360,确保差值始终落在 -180 到 180 度之间,摆动方向就总是正确的了。
另外还有一个容易被忽略的问题:Date.now()获取的时间戳是毫秒级,但帧与帧之间的时间差如果太小,计算出来的速度会异常大。我在实际测试中发现,部分高刷设备上两帧之间间隔只有 6 到 8 毫秒,此时一次轻微移动就会被算成巨大初速度,转盘显得很“神经质”。所以后来我加了一个平滑处理:速度不是直接用单帧差值,而是取最近 3 帧的平均速度,手感一下子就稳了。
惯性阶段用Animator或requestAnimationFrame驱动都可以。我在 V1.0 里选择用animateTo的离屏版本实现,本质上和setInterval每 16 毫秒更新一次角度是一样的。项目里我保留了速度计算接口,方便以后接入更真实的物理引擎。
4.3 轻触反馈与“转完即知”的安心感
鸿蒙提供了震动反馈能力,我在两个时机用到了它:手指开始拨动时给出一个轻微短震,提示“已捕获到操作”,转盘完全停止时给出一个稍长的震动,提示“结果出来了”。
这里有一个需要拿捏的细节:震动强度不能相同。开始拨动的震感应该是 5 到 8 毫秒的微震动,让用户感觉到“转盘和你建立了联系”,但不能强到让人误以为出错;停止时的震感可以稍微强一点,但不能超过 15 毫秒,否则会显得笨重。
音响反馈方面,我准备了几段 1 秒以内的无版权白噪音和柔和提示音,在转盘滑过每个扇区标记点时播放极短的一声“嗒”,模拟传统转盘拨针划过扇区的声音。
// 震动反馈示例 import { vibrator } from '@kit.SensorKit'; private giveHapticFeedback(phase: 'start' | 'stop'): void { if (!this.hapticEnabled) return if (phase === 'start') { vibrator.startVibration({ type: 'time', duration: 8, usage: 'touch' }).catch((err: Error) => { console.error(`震动失败: ${err.message}`) }) } else if (phase === 'stop') { vibrator.startVibration({ type: 'time', duration: 12, usage: 'physicalFeedback' }).catch((err: Error) => { console.error(`震动失败: ${err.message}`) }) } }如果用户的手机不支持震动模块,startVibration会走 catch 分支,不会崩溃,日志里也能看到错误信息,方便排查。这个 API 还有一个要注意的地方:部分定制 ROM 会把震动权限放到系统设置里,应用无法强行开启,所以代码里要容忍失败,不能因震动失败影响主流程。
文案显示的“转完即知”指的是:转盘停止后 300 毫秒内,结果卡片必须完整显示出来。我在转盘减速阶段就预先算好停靠扇区,一旦速度归零,立刻触发状态更新,加载对应彩蛋数据。这个过程不涉及网络请求,数据是本地内置的,所以能做到即停即显。如果后续要接入服务端文案,就得考虑预加载策略,不能把网络延迟暴露给用户。
4.4 轻量分布式能力:摇一摇分享结果
标题里提过“轻快乐的情绪价值迷恋者”,说白了就是用户转到一个好玩的文案后,可能会想分享给朋友。V1.0 里我实现了一个轻量分享,核心是应用内生成一张带转盘当前结果的海报图,然后拉起系统分享面板。
HarmonyOS 的分布式能力在这个场景里可以用得很轻:如果用户的手机和平板登录了同一华为账号,平板上会同步显示当前正在使用的彩蛋卡片,方便用户在大屏上继续查看或编辑。这个功能我没有用复杂的分布式数据管理框架,而是用了轻量级的continuation能力,将当前状态封装成可序列化对象,通过系统自带的跨端协同机制流转。
这里有一个重要的实现原则:用系统能力时要控制好使用面,不要为了让应用“看起来很分布式”而引入不必要的依赖。轻量协同的本质是把状态同步过去,同步的内容越少,链路越稳定。我只同步了彩蛋文本、主题颜色和转盘角度三个字段,整个数据包不到 200 字节,流转延迟体感上基本无感。
对于更复杂的分布式场景,比如用户在平板上继续编辑自定义彩蛋库并回传到手机,V1.0 没有做,原因是协同数据一旦涉及双向写,就有冲突处理问题,开发成本会显著上升。我把这部分留到 V2.0 规划里。做项目就是这样,明确“什么不做”和明确“做什么”同样重要。
5. 实操过程与关键参数调试记录
5.1 开发环境与工程结构搭建
使用 DevEco Studio 新建 HarmonyOS 工程时,我选择的是Empty Ability模板,因为我不想让模板自带的示例代码干扰项目结构。开发版本基于 API 12,目标设备的compileSdkVersion设为 12,最低支持版本设为 9,这样能覆盖市面上绝大多数鸿蒙设备。
工程目录方面,我把资源、模型、工具和页面拆开了:
entry/src/main/ets/ ├── pages/ │ ├── Index.ets // 转盘主页面 │ ├── DetailPage.ets // 彩蛋详情页 │ └── SettingsPage.ets // 设置页 ├── view/ │ ├── SpinWheelCanvas.ets // 转盘画布组件 │ └── ResultCard.ets // 结果卡片组件 ├── model/ │ ├── EggModel.ets // 彩蛋数据模型 │ └── AppConfig.ets // 全局配置 ├── common/ │ ├── ThemeManager.ets // 主题管理 │ └── Constants.ets // 常量定义 └── utils/ ├── AngleUtils.ets // 角度计算工具 └── HapticUtil.ets // 震动反馈工具这个结构看起来不复杂,但它是模块化的,后面加新页面、新功能基本不会动到已有文件的依赖关系。
真机调试环节我想多说一句。鸿蒙应用开发里,模拟器虽然方便,但动画性能和震动反馈这种强依赖传感器的功能,模拟器表现和真机差距很大。比如同样的转盘惯性动画,模拟器上帧率稳定在 60,但真机上开了省电模式后掉到 30 甚至更低,手感完全不同。所以从第一天起我就坚持用真机验证每个交互节点,模拟器只用来做布局预览。
5.2 手感调参:从“能用”到“好玩”的关键数据
转盘应用最有技术含量的地方不是画图,而是调手感。手感这个词听起来玄学,但本质上就是几个数值的组合。我把调试过程中的关键数据和效果对照记录下来。
速度衰减系数。
衰减系数决定松手后转盘能转多久。系数越接近 1,转动时间越长。我测试了 0.95、0.97、0.98、0.99 四档。0.95 衰减太快,转不到一圈就停了,反馈感不足;0.99 衰减太慢,用户要等 8 秒才能看结果,焦躁情绪会上升;0.98 和 0.97 差距不大,最后选了 0.98,配合速度阈值 1 度/秒,整体转动时长控制在 3 到 5 秒,刚好符合“有点期待但不急”的节奏。
速度阈值。
这个值决定何时判定“转动结束”。阈值设太大,比如 5 度/秒,转盘明明还在缓慢滑动就强行停了,视觉上会有一瞬间的不自然;阈值设太小,比如 0.1 度/秒,结束判定会延迟 1 秒以上,用户干等着。1 度/秒是平衡后的结果。
手感放大系数。
手指移动 1 度,转盘要不要转 2 度?我试过 1:1、1:1.5、1:2 三个比例,最终用了 1:1.8。1:1 太迟钝,用户拨了很大幅度,转盘才转一点点,挫败感强;1:2 太灵敏,轻碰一下就滚很远,容易误触;1:1.8 是手指刚感到有阻力、转盘就会给出明显响应但又不过度的区间。
中心按钮点击后的“轻推亦转”。
如果用户不拨动,只点击中心按钮,转盘要有一个自动旋转动作。这里的随机角度范围也要调:太小的范围看起来不过瘾,太大的范围让用户等太久。我用的是 360 度到 540 度之间的随机值,配合 0.98 衰减,自动旋转停稳大概需要 3.8 秒。
这些参数我全部定义在Constants.ets里,方便统一修改。为什么强调这一点?因为参数调优是反复实验的过程,一天可能要试几十组数值,如果参数散落在各个组件里,改一次要找半天,而且很容易漏改。集中管理之后,每次调整只需要改常量文件,热重载后立刻验证效果,效率能翻倍。
5.3 主题系统与文案库的组织方式
主题系统和文案库是转转乐“情绪价值”的载体,也是内容更新的核心。我把它们解耦成两个独立的数据源。
主题系统用的是“调色板”模式,每个主题只是一个包含多种颜色的数组。比如“蜜桃乌龙”主题包含转盘扇区用色、背景用色、卡片用色、文字用色一共四类颜色的组合。“极夜黑金”主题则把整体色温降低,适合夜间使用。切换主题时,UI 组件从ThemeManager读取当前主题的色板值,Canvas重绘资金盘,普通组件更新背景和卡片色,整个过程不需要重新构建页面。
文案库我设计成了 JSON 格式,每条彩蛋记录包含正文、短标签、建议场景和背景色。内置了 48 条初始文案,分为“行动派”“治愈系”“搞怪风”三类。“行动派”的文案是“去楼下散步十分钟”“给很久没联系的朋友发个消息”;“治愈系”偏向自我关怀,“你已经做得足够好了”“今天允许自己休息一下”;“搞怪风”负责制造轻松感,“你刚才好像忘记带钥匙了,对吗”。
自定义文案是设置页的核心功能,用户输入新文案后存到本地首选项里,下次启动依旧存在。这里有一个体验细节:自定义文案的审核逻辑我没做,因为这是单机应用,用户自己写的文案自己看到,不涉及内容平台传播,也就不需要内容过滤。但如果文案库将来要走云端同步,就必须考虑内容安全和同步冲突,这部分留给后续版本。
提示:文案库的 JSON 文件建议放在
rawfile目录下,不要硬编码在.ets文件里。好处是更新文案时只需要替换资源文件,不需要重新编译代码,而且后续如果接入云端下发可以平滑切换。
5.4 状态恢复与生命周期管理
用户正在转盘转到一半,突然接了个电话,回来后发现转盘状态没了——这种体验绝对不能接受。所以生命周期管理是关键。
我在aboutToAppear阶段读取持久化数据,把上次的转盘角度、当前选中主题、彩蛋自定义列表恢复出来;在aboutToDisappear阶段把当前状态写回首选项。persistentStorage.PersistProp是鸿蒙提供的简化方案,它能自动把指定属性与持久化存储绑定,属性值变化时自动入库,启动时自动恢复。
不过这里也有个坑:PersistProp只支持基本类型和可序列化对象,复杂嵌套的自定义类对象需要实现序列化接口。我的做法是只持久化转盘角度、主题 ID 这些基本类型,自定义文案库单独序列化为 JSON 字符串后存储,避免直接把类对象塞进持久化接口。
还有一些细节值得留心:转盘转动过程中如果页面被销毁,定时器必须清理,否则会在后台空转浪费资源。我在组件销毁回调里统一调用stopAnimation(),同时把定时器置空。这个清理逻辑看似简单,但遗漏的话会导致不可预期的卡顿和内存占用。
6. 常见问题与排查技巧实录
6.1 转盘锯齿和模糊问题
这个问题在真机上特别明显,通常有两种表现。
第一种是扇区边缘出现锯齿。解决方法是确认RenderingContextSettings的antialias参数为true,如果已经是true,检查 Canvas 组件的宽高是否等于实际绘制分辨率。Canvas 的默认绘制区域和组件显示尺寸不一致时,可能会出现边缘模糊。
第二种是文字模糊。原因通常是绘制字体时使用了非整数坐标,在部分设备的缩放机制下,0.5像素偏移会被放大成明显的模糊。解决方案是把fillText的坐标用Math.round()取整。这个优化在很多设备上能直接消除肉眼可见的模糊。
6.2 高刷设备上的速度抖动
第一次在 120Hz 刷新率的设备上测试时,我明显感觉到转盘转速忽快忽慢。问题根源在于手势事件上报频率和帧率不完全同步,单帧速度计算不稳定。我的解决方法是取最近 3 帧的平均速度作为当前速度,同时在惯性阶段用固定步长的时间增量而不是帧数来更新角度,这样即使掉帧也不会影响整体物理表现。
还有一个约束条件:不要试图在每一帧都刷新 UI。ArkUI 的Canvas重绘是有成本的,在 60Hz 设备上跑满 60fps 没有问题,但 120Hz 设备上如果每帧都重绘,部分中端机型会发热掉电。我后来加了角速度的阻尼平滑,在视觉流畅的前提下把实际重绘频率控制在 60fps 以下,性能和体验取得平衡。
6.3 卡片动画与转盘停止顺序错乱
V1.0 早期版本有一个 bug:转盘还在减速滑动时,如果用户快速点了一次中心按钮,结果卡片会先显示新结果,但转盘还在继续滑动到旧位置,导致卡片和指针指向不一致。
排查后发现是状态并发问题:转动结束回调和新一轮旋转启动回调同时触发,结果卡片的状态被新动作提前更新了。修复方法是在启动新的旋转动作之前,先检查是否正在转动中,如果isSpinning为真,就丢弃这次启动请求;同时在结果回调里增加一个递增的spinId标识,只有最新一次转动的结果才允许更新卡片状态。
这类竞态问题在 UI 开发里非常典型,尤其是用户操作速度快的时候。经验就是:任何异步结果回来时,都要先确认这个结果是不是还有效,是不是已经被更新的操作覆盖。用代数序号校验是最简单可靠的方式。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 转盘偶发反向旋转 | 触摸角度跨 360 度边界 | 对角度差限制在 -180 到 180 度区间 |
| 松手后转盘抖动 | 单帧速度计算异常 | 使用最近 3 帧平均速度 |
| 转盘边缘锯齿化 | 抗锯齿未开启 | RenderingContextSettings(true) |
| 卡片文字模糊 | 非整数坐标绘制 | fillText坐标取整 |
| 转盘停止位置和结果不符 | 状态并发竞争 | 每次转动分配spinId,仅最新结果生效 |
| 长时间播放后掉帧 | 定时器未及时清理 | 页面销毁时停止动画并置空定时器 |
| 震动无反馈 | 设备不支持或系统权限关闭 | 捕获异常并降级处理,不影响主流程 |
| 自定义文案重启丢失 | 持久化类型不支持 | 序列化为 JSON 字符串后存储 |
7. 体验优化方向:进阶玩法的技术储备
7.1 引入 Lottie 动画强化彩蛋表现
现在 V1.0 的彩蛋结果主要是静态卡片配合简单的位移动画,视觉上比较克制。后续想升级的话,我计划引入 Lottie 动画来表现彩蛋场景。比如转盘停到“去散步”这个彩蛋,卡片入场时带一个微风吹过树叶飘散的动画;停到“喝杯奶茶”时,背景浮现奶茶杯的轮廓描边动画。
从技术角度看,Lottie 在鸿蒙上有对应的社区适配库,将设计师导出的 JSON 动画文件放到rawfile目录,运行时通过动画组件渲染即可。这里有个优化点:不要为每条彩蛋提前加载 Lottie 文件,而是在转盘停止的那一瞬间动态加载对应文件。配合预加载策略,可以把加载耗时从 100 毫秒左右压到用户可感知的阈值以下。
7.2 传感器融合:摇一摇与翻转彩蛋
鸿蒙的传感器框架已经支持加速度计、陀螺仪、旋转矢量传感器。加上这些之后,用户拨动转盘之外,多了一种操作方式:摇一摇手机随机转一次;翻转手机翻开结果卡片。这两种操作非常适合“轻快乐”的应用气质。
实现上需要注意传感器事件频率不能太快,否则耗电会明显增加。建议把传感器采样频率设置为SENSOR_SAMPLING_RATE_NORMAL,约 20 到 50 赫兹,足以捕捉摇一摇动作。触发条件用传感器事件中加速度模长的峰值判断,阈值为 18 米/平方秒,持续 3 帧以上才认定为一次有效摇动,防止误触。
7.3 转盘结果分享海报的样式升级
V1.0 的分享海报是在本地 Canvas 里绘制固定模板,标题、文案和二维码位置固定。后续优化方向是做多模板选择和文字排版自适应。长文案和短文案要对应不同的字号和行距,模板要能根据文案长度自动切换布局。
技术实现上,分享海报的绘制逻辑和转盘绘制是同一套 Canvas API,可以在后台离屏创建一块画布,生成位图后转成 PixelMap 传给系统分享接口。这里要注意大图生成时的内存开销,建议画布尺寸不超过设备分辨率的 2 倍,生成完成后要及时释放 PixelMap 引用。
7.4 多端分布式体验的进阶
V1.0 的分布式流转是单向的、小数据量的状态同步。进阶版本可以做成手机与平板的双向编辑协同:手机自定义的文案实时出现在平板的转盘上,平板调整的主题回传到手机。更复杂一点的玩法是手机和平板同时转动各自的转盘,结果同步汇总到大屏上展示,形成一种“双人互动转盘”的派对玩法。
实现这类协同的关键是选择合适的数据通信方式。HarmonyOS 的分布式数据管理支持跨设备键值同步,但需要注意同步冲突策略。每条彩蛋记录需要一个lastModifiedTime时间戳和deviceId来源标识,冲突时以时间戳大者为准。这个机制在小规模数据量下足够稳定,也不需要自建服务端。
8. 写在最后的几点经验
我在做转转乐的过程中,最深的体会是:技术永远服务于体验。画转盘、做动画、调参这些事,最终目的都是让用户在打开应用的几秒钟里感受到一点快乐。可能不解决问题,不提高效率,但能让人嘴角微微上扬。这种“没有用处的用处”,恰恰是情绪价值类应用的核心价值。
再分享一个小技巧:做这种应用,一定要在真机上反复“玩”它。开发过程中我每天都把转转乐当成真正的用户那样玩几十次,每一次都留意转盘停下来时自己的心情变化。某个参数调完之后,如果自己玩的时候不再有“想再转一次”的冲动,那一定是手感出了问题。工具型应用可以用指标来衡量好坏,但情绪价值型应用的好坏标准只有一个——你自己愿不愿意一直玩下去。
转转乐 V1.0 目前已经能稳定跑在手机上,除转盘主流程外,彩蛋详情、自定义文案、主题切换、持久化恢复、系统分享这些外围功能都已就位。后续版本的方向也很清晰:引入 Lottie 动画丰富彩蛋表现,加入摇一摇和翻转传感器交互,再做跨设备协同玩法。
做应用就是这样,第一版永远有遗憾,但也正是这些遗憾指明了下一版的方向。鸿蒙生态还在快速发展,对我个人来说,能在这个时间点用代码把“情绪价值”这个相对抽象的词变成一个能触摸、能旋转、能分享的小应用,本身就是一件值得记录的事。希望你也能在自己的项目里找到那种“转一下就很开心”的感觉。