【HarmonyOS NEXT初体验】轮盘组件开发实战----选择困难症必备

390 阅读16分钟

本文正在参加华为鸿蒙有奖征文征文活动


之前学了下ArkTs,但一直都没怎么开始实战,主要是没有华为的设备。正好这次有机会可以在掘金申请鸿蒙next的虚拟机使用体验,所以这次想做一个小的组件来巩固下自己学习内容,决定选择做一个就很常见的轮盘组件,下面讲一下自己的开发思路与想开发的功能。

一、Demo效果

demo效果

这是初步想的一个功能,当然。转盘不只是做这些决策,也可以去做一些抽奖功能,如果要做抽奖功能,肯定是要可以给一等奖、二等奖之类的不同奖项设置概率,不可能完全的平均分,这也是必须要考虑到的。另外,为了方便做成组件复用,所以自己考虑的是用纯代码的形式去实现,能够做到直接复制过去用是最好的了,下面就按照掘金官方给的文章格式来描述下本项目。

二、应用介绍

本应用/元服务的名称叫《做个决定》。简介如下:

“做个决定”是一款超实用的转盘及骰子应用。当你在多个选项(如 ABCD)之间纠结而不知如何抉择时,只需将这些纠结的选项输入转盘中,它就能帮你迅速做出选择,不管是晚上吃什么、谁来做家务,还是如何惩罚男朋友、玩真心话大冒险等都不在话下。同时,这款软件还支持大话骰,无论是玩飞行棋时没有骰子,还是在 KTV 里骰子不够用,亦或是和朋友喝酒时想通过摇骰子来助兴,它都能完美应对。此外,它还具有掷骰子比点数比大小以及指尖轮盘等功能。 本软件通过随机算法生成结果,一切都交给缘分,快来下载体验吧!

特性:

  1. 转盘具备自动和手动滚动两种模式。
  2. 支持 KTV 喝酒摇骰子的大话骰。
  3. 可以进行掷骰子(摇色子)比点数比大小。
  4. 拥有指尖轮盘。
  5. 可以用于生成随机数,用于视频or评论楼层抽奖。

三、需求分析

3.1 核心受众

本应用核心受众人群包括以下特征:

  • 年龄:较为广泛,以年轻人为主,比如 15 岁到 40 岁左右的人群,他们对新鲜事物接受度高,且在生活中经常面临各种选择。
  • 城市:各类城市的人群都有可能,尤其是生活节奏较快、社交活动较多的城市,如一二线城市。
  • 喜好:喜欢享受美食但不知道吃什么的人;喜欢简单便捷做决定的人;喜欢玩真心话大冒险等互动游戏的人;经常参与社交聚会,如在 KTV、酒吧等场所活动的人;平时在一些小事情上纠结,需要一个随机方式来辅助决策的人。

3.2 使用场景

以下是一些具体化的使用场景:

  • 朋友聚会时,大家可以用这个“做个决定”来决定玩什么游戏、去哪家餐厅吃饭、谁先开始表演节目等。
  • 在家庭中,例如周末一家人商量去哪里玩时,可以通过转盘来做决定;或者决定谁去洗碗、拖地等家务分工。
  • 公司团队活动时,比如决定活动项目、分组方式等。
  • 情侣之间,像是决定约会地点、看什么电影,甚至是一些小惩罚方式等都可以借助该应用。
  • 课堂上老师可以用它来随机挑选学生回答问题。
  • 个人在面对多个购物选项犹豫不决时,用它来决定买哪一个。
  • 旅行前,用转盘决定去哪里旅行、住哪家酒店等。

3.3 解决痛点

  • 选择困难:帮助那些在多个选项间难以抉择的用户快速做出决定,节省纠结时间。
  • 决策公平性需求:确保决策过程是随机且公平的,避免人为因素的干扰和争议。
  • 缺乏趣味:为一些日常场景如真心话大冒险等增添了趣味性和娱乐性。
  • 资源不足:解决了玩某些游戏时缺少骰子等工具的问题。
  • 避免争执:在需要分配任务或做出选择时,有效避免因意见不同而产生的争执和矛盾。

四、作品创意/竞争力

4.1 创新点

  1. 指尖轮盘:增加了独特的操作方式,让用户可以通过手指滑动来模拟转盘,带来更直观和有趣的操作体验。
    1. 自动和手动模式满足了不同用户的使用习惯和需求,用户可以根据喜好自由选择,提升了灵活性。
    2. 在朋友聚会玩游戏时,指尖轮盘可以让用户更自由地控制节奏,增加互动乐趣。比如在玩一些需要快速决定的游戏时,用户可以更便捷地操作。
    3. 对于那些喜欢自己掌控转盘节奏的用户,手动模式能让他们更好地参与到决策过程中,提升参与感;而自动模式则适合那些想要更快速得出结果的场景,如快速决定吃什么,节省时间。两种模式的存在让用户在不同场景下都能获得更好的体验
  2. AI生成选项:用户可以输入一些关键信息,由 AI 根据这些信息自动生成相关的选项。
    1. 比如输入“周末活动”,AI 能生成如看电影、逛街、爬山、游泳等一系列适合周末进行的活动选项;输入“五月份适合吃什么”,直接列出五月份的当季美食。
    2. 这极大地拓展了用户的选择范围,也减少了用户自己思考和输入选项的麻烦。
    3. 用户首先利用AI提供基本的一些内容,然后进行自己更加精细化的修改,然后最终进行轮盘选择,最终实现完美的使用体验。甚至可以推荐给他人分享,
    4. 总结就是,在用户一时间想不出具体选项时,AI 生成选项功能可以快速提供丰富的可能性,让决策过程更加顺畅。比如在决定家庭活动时,用户可能思维局限,而 AI 生成的各种新颖选项能带来更多惊喜和创意,让决策变得更加有趣和多样化。

4.2 基于鸿蒙特性

在这款“做个决定”应用中,可以与鸿蒙系统的服务卡片结合。思路如下: 可以设计一个服务卡片,上面直接显示一个简单的转盘或者骰子的图标。当用户点击这个服务卡片时,直接快速启动转盘功能或进入骰子界面。比如服务卡片上可以显示最近一次使用的转盘选项内容,用户直接在卡片上就能看到并快速进行选择操作。或者在服务卡片上展示一个骰子的点数,代表上次掷出的结果。这样用户无需打开整个应用,就能在桌面便捷地利用服务卡片进行一些快速的决定操作或查看相关信息,极大地提升了应用使用的便捷性和效率,也增加了应用与用户交互的及时性和直接性。而且服务卡片可以根据用户的使用习惯和偏好进行个性化定制,以更好地适应不同用户的需求。

4.3 相较于其他APP

事实上在其他移动设备系统,这类的APP软件非常多,那本款应用有什么特性呢,我想了下,总结如下:

  1. 基于Harmony OS Next系统,力争做到完全无广告。
    1. 在如今众多 APP 充斥着各类广告的环境下,本应用基于鸿蒙 next 系统,坚持做到完全无广告,这无疑为用户提供了一个极为纯净的使用环境。用户无需担心在使用过程中被广告打断或干扰,能够全身心地投入到决策和娱乐过程中。这种无广告的设计体现了对用户体验的高度尊重和极致追求,让用户能够更加专注和享受应用带来的功能和乐趣。
  2. 充分调用手机or其他设备的振动马达,比如说,当轮盘转动时,每滑动一定内容,做出相应的震动反馈,并且全程的震动反馈不是线性的,应该是跟转盘一样,从最开始的速度快到逐渐的变慢,震动要做出配合,从一开始的强度较大,慢慢变弱,给用户一个比较好的体验。
    1. 通过巧妙地利用设备的振动马达,当轮盘转动时,每滑动一定内容就给予相应的非线性能震动反馈,从开始的快速强烈到逐渐变慢变弱,与转盘的转动节奏相契合。这样的设计能让用户在操作过程中获得更加身临其境的感受,仿佛真的在转动一个实体的转盘。这种独特的触觉反馈极大地增强了用户与应用之间的交互感和沉浸感,为用户带来与众不同的优质体验,进一步提升了应用的吸引力和趣味性。它不仅仅是视觉和听觉上的呈现,更是通过触觉维度为用户打造了一个更加丰富和多维的使用体验。

五、可行性评估

5.1 关键技术功能

  • 实现转盘或骰子等功能在技术上相对较为常规,主要涉及图形绘制和算法逻辑,难度适中。
    • Harmony OS Next ArkUI的canvas API接口调用
    • 利用 animateTo 接口API实现流畅动画功能
  • 与鸿蒙特性的结合,如调用振动马达,需要对鸿蒙系统的接口有深入了解,但也并非不可实现。

5.2 UX 交互实现难度

  • 设计流畅自然的交互体验,如手指滑动转盘的响应等,需要精心调试和优化,但通过合理的设计和开发可以达成。
  • 振动反馈的精确控制需要一定的技术手段和测试,但也是可行的。

5.3 总体评估

  1. 整体技术可行性:基于鸿蒙系统开发具备可行性,鸿蒙提供了丰富的开发资源和工具。而且现有的技术能力足以支持这些功能的实现。
  2. 实际开发工作量评估:需要一定的时间投入来进行功能开发、界面设计、与鸿蒙特性的整合以及交互优化等方面,但考虑到这些功能的相对常规性和现有技术手段,是可以在合理时间内完成的,预计每天四小时,不超过一个月时间(具体得看最终想要实现的效果已经完成度)
  3. 产品可落地的理由
  • 技术上不存在无法克服的障碍,通过合理的开发计划可以实现。
  • 市场上对这类应用有需求,尤其是具有独特体验的产品会有吸引力。
  • 鸿蒙系统的发展和普及为产品提供了良好的平台基础。
  • 可以通过逐步迭代的方式,先实现核心功能,再不断完善和优化,确保产品能够顺利落地并持续发展。
  • 这本身也是自己学习鸿蒙开发的一个小项目,不存在盈利,只为好的用户体验。

六、部分具体实现代码

这里讲一下轮盘抽奖设计的一个初步思路,之前用JS+HTML+CSS实现过,但是因为自己的模拟器体验还在审核中,我为了赶上5.27发文章的早鸟奖,所以这里只能讲下思路,等审核成功后再用ArkTs和声明式UI将其实现为具体的代码。

开发日程:

5.27:已拿到next 虚拟器设备的资格,准备开发!✈

5.30:轮盘组件已开发完毕!💿

  1. 确定基本结构 首先明确转盘的整体形状和布局,划分出相应的区域,为每个区域设定对应的结果。同时确定抽奖的规则和流程,包括如何启动抽奖、抽奖次数限制等。
  2. 概率分配 根据需求设定不同的选项与奖励,并为每个奖品分配特定的出现概率。并且要为了复用,对于使用组件的人,要能做到自定义各个奖励抽中的概率。
  3. 触发机制实现 设计一个简洁有效的触发方式,如点击按钮或滑动操作等。在代码中实现对触发动作的响应,进而启动抽奖程序,触发后要确保转盘能快速、流畅地开始转动。
  4. 随机算法运用 运用合适的随机算法来决定抽奖结果。可以利用编程语言中的随机数生成功能,并结合概率分配来确定最终获得的奖品区域。同时要进行多次测试,以验证随机结果的公正性和合理性。
  5. 转盘转动与结果展示 实现转盘的转动动画与震动效果,让其看起来更加生动真实。当转盘停止后,准确显示出对应的奖品或结果,并且要有明确的提示和反馈,让用户清楚知道自己获得了什么。
  6. 数据记录与分析 在抽奖过程中对相关数据进行记录,这些数据可以用于后续的分析和优化,以便更好地改进抽奖机制和提升用户体验。
  7. 安全与稳定性保障 确保抽奖机制在各种情况下的安全稳定运行,防止出现作弊行为或系统故障。对代码进行严格的测试和优化,以应对高并发等情况。
  8. 用户交互与界面设计 设计友好的用户交互界面,包括转盘的外观、操作按钮的位置和样式等。让用户能够轻松理解和参与抽奖,同时提供良好的视觉感受。

轮盘界面实现完整代码如下:(直接复制即可)

import promptAction from '@ohos.promptAction'
interface Item{
  location : number
  rate : number
  name : string
  min : number
  max : number
}

@Component
export struct TurntablePage {
  //用来配置CanvasRenderingContext2D对象的参数
  //包括是否开启抗锯齿,true表明开启抗锯齿。
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  //用来创建CanvasRenderingContext2D对象,通过在canvas中调用CanvasRenderingContext2D对象来绘制。
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)

  @State sum: number = 6;
  @State turntableWidth: number = 320;
  @State turntableHeight: number = 320;
  @State turntableRadius: number = 160;
  @State isGoing:Boolean = false;
  @State circle:number=0;
  @State rotateDegree:number =0;

  @State angle:number = 0;

  @State options: Item[] = [
    {location:1, name: "金拱门",rate:20,min:0,max:0 },
    {location:2, name: "开封菜",rate:20,min:0,max:0 },
    {location:3, name: "汉堡王",rate:20,min:0,max:0 },
    {location:4, name: "达美乐",rate:20,min:0,max:0 },
    {location:5, name: "谢宝林",rate:20,min:0,max:0 },
    {location:6, name: "小馋猫",rate:20,min:0,max:0 },
  ]

  @State toEat:string ="? ? ?"

  build() {
    Column() {
      //title
      Column() {
        Row() {
          Image($r('app.media.edit')).width(16)
          Text("今晚吃什么")
        }
        .margin({ top: 0, right: 0, bottom: 40, left: 0 })
        .backgroundColor('rgb(246,246,246)')
        .padding(10)
        .borderRadius(10)

        Text(this.toEat).fontSize(18)
      }

      //turntable-wrap
      Column() {
        //turntable
        Column() {
          //bg
          Column() {
            //canvas
            Canvas(this.context)
              .width(this.turntableWidth)
              .height(this.turntableHeight)
              .onReady(this.drawTurntable)
              .rotate({
                angle: this.rotateDegree,
              })
          }
          .position({ x: 0, y: 0 })
          .width("100%")
          .height('100%')
          .border({ radius: 500 })

          //pointer
          Column() {
            Text("〇")
              .backgroundColor("rgb(255,255,255)")
              .width(100)
              .height(100)
              .border({ radius: 500 })
              .shadow({ radius: 30, color: '#1F000000' })
              .textAlign(TextAlign.Center)
              .fontSize(60)
              .fontWeight(FontWeight.Medium)
              .fontColor("rgb(213,213,213)")
          }
          .width("100%")
          .height("100%")
          .justifyContent(FlexAlign.Center)
          .alignItems(HorizontalAlign.Center)
          .onClick(() => {
            //1.计算出抽奖结果
            const result = this.gameStart();
            if (result !== null){
            //2.计算出最终需要的旋转角度
            this.angle = (result.location - 1) * (360 / this.options.length);
            // 3.开始抽奖(重新绘制canvas)
              animateTo({
                duration: 4000,
                curve: Curve.Ease,
                delay: 0,
                iterations: 1,
                playMode: PlayMode.Normal,
                onFinish: () => {
                  this.rotateDegree = 360 - this.angle;
                  // 打开自定义弹窗,弹出抽奖信息
                  this.toEat=result.name
                }
              }, () => {
                this.rotateDegree =360* 5 + 360 - this.angle;
              })
            }
          })

          //小三角
          Text("")
            .position({ x: 140, y: 80 })
            .border({ width: 20 })
            .borderColor({
              bottom: "white", top: "rgba(0, 0, 0, 0.00)"
            , right: "rgba(0, 0, 0, 0.00)", left: "rgba(0, 0, 0, 0.00)"
            })
        }
        .width(320)
        .height(320)
        .position({ x: 10, y: 10 })
        .borderRadius(500)
      }.width(342)
      .height(342)
      .borderRadius(500)
      .shadow({ radius: 20, color: '#1F000000' })


      Row() {
        Button("还原转盘")
          .height(38)
          .fontSize(16)
          .type(ButtonType.Normal)
          .fontWeight(FontWeight.Medium)
          .borderRadius(8)
          .backgroundColor('rgb(255,255,255)')
          .fontColor('rgb(0,0,0)')
          .borderWidth(1)
          .onClick(()=>{
            animateTo({
              duration: 4000,
              curve: Curve.Ease,
              delay: 0,
              iterations: 1,
              playMode: PlayMode.Normal,
              onFinish: () => {
                this.isGoing=false;
                this.toEat="? ? ?"
              }
            }, () => {
              this.rotateDegree =0;
            })
          })
      }
    }.turntablePageBgStyle()
  }

  //绘制canvas的函数
  drawTurntable = (): void =>{
    //可以在这里绘制内容。
    const centerX = this.turntableWidth / 2;
    const centerY = this.turntableWidth / 2;
    const radius = this.turntableRadius;
    const colors = [
      'rgb(191,193,165)',
      'rgb(246,198,246)',
      'rgb(152,202,247)',
      'rgb(205,192,219)',
      'rgb(248,211,169)',
      'rgb(160,221,175)',
      '#c9d4f7',
      '#fcc351',
      '#eeeaeb',
      '#a4ceb7',
      '#feb2da',
    ];
    for (let i = 0; i < this.sum; i++) {
      const startAngle = i * (Math.PI * 2 / this.sum) - (Math.PI / 2) - (Math.PI / this.sum); // 计算当前扇区的起始角度
      const endAngle = (i + 1) * (Math.PI * 2 / this.sum) - (Math.PI / 2) - (Math.PI / this.sum); // 计算当前扇区的结束角度
      this.context.beginPath(); // 开始绘制路径
      this.context.moveTo(centerX, centerY); // 将路径起点移动到圆心
      this.context.arc(centerX, centerY, radius, startAngle, endAngle); // 绘制一个弧(扇区)
      this.context.fillStyle = colors[i]; // 设置当前扇区的填充颜色
      this.context.fill(); // 填充当前扇区
      this.context.fillStyle = 'white'; // 设置文字颜色为黑色
      this.context.font = '50px'; // 设置文字字体
      this.context.textAlign = 'center'; // 设置文字水平对齐方式为居中
      this.context.textBaseline = 'middle'; // 设置文字垂直对齐方式为居中

      //在扇区上绘制对应的文字
      this.context.fillText
      (this.options[i].name,
        centerX + Math.cos(startAngle + (endAngle - startAngle) / 2) * radius * 0.7, centerY
          + Math.sin(startAngle + (endAngle - startAngle) / 2) * radius * 0.7);

      // 绘制扇区分割线
      this.context.beginPath();
      this.context.lineWidth = 2.5; // 设置线宽
      this.context.strokeStyle = 'white';
      for (let i = 0; i < this.sum; i++) {
        this.context.moveTo(centerX, centerY); //将画笔移动到圆心坐标
        this.context.lineTo(
          centerX + radius * Math.cos((Math.PI / 2 - (Math.PI / this.sum)) % (Math.PI * 2 / this.sum) + i * (Math.PI * 2 / this.sum)),
          centerY + radius * Math.sin(
            -((Math.PI / 2 - (Math.PI / this.sum)) % (Math.PI * 2 / this.sum) + i * (Math.PI * 2 / this.sum))
          ))
      }
      this.context.stroke();
    }
  }
  //计算中奖结果
  gameStart = ():Item | null =>{
    //是否开始
    if (this.isGoing) {
      promptAction.showToast({
        message: `请先还原转盘`,
        duration: 2000,
        bottom: 75
      });
      return null;
    }
    this.isGoing = true;

    // 1. 随机中奖结果
    // 从1-totalRate之间得到一个随机数,看这个随机数在中奖设置的范围,得到最终中奖的项
    const totalRate = this.options.reduce((sum, item) => sum + item.rate, 0);
    let randomRate = ~~(Math.random() * totalRate) // ~~ == Math.floor()
    // 设置中奖数据的概率范围
    let num = 0
    this.options.forEach(item => {
      item.min = num;
      num += item.rate;
      item.max = num;
    })
    // 根据随机数,得到中奖结果
    let res = this.options.filter(item => {
      return randomRate >= item.min && randomRate < item.max;
    })[0];
    // 这儿可以根据实际情况,可重置中奖结果(强制非酋😅)
    return res;
  }


}
@Extend(Column) function turntablePageBgStyle() {
  .width('100%')
  .height('100%')
  //justifyContent:主轴的对齐方式为均匀分布
  .justifyContent(FlexAlign.SpaceEvenly)
}

@Extend(Button) function buttonStyle()
{
  .width(240)
  .height(48)
  .fontSize(16)
  .type(ButtonType.Normal)
  .fontWeight(FontWeight.Medium)
  .borderRadius(8)
}

本文正在参加华为鸿蒙有奖征文征文活动