HarmonyOS NEXT 实战:打造功能完整的秒表应用
秒表(Stopwatch)是移动开发中最经典的入门项目之一。看似简单,实则涵盖了定时器管理、状态控制、列表渲染、数据处理等多个核心知识点。对于刚接触 HarmonyOS NEXT 的开发者来说,这是一个绝佳的进阶练手项目。知识点应用场景定时器实现毫秒级计时状态变量@State控制计时器的启动/暂停/重置条件渲染if动态显示运行状态ForEach列表渲染展示计次记录数据接口定义封装圈数、单圈时间、总
HarmonyOS NEXT 实战:打造功能完整的秒表应用
API版本:6.1.1(24)
目标设备:Phone
开发工具:DevEco Studio
一、前言:为什么选择秒表作为练手项目?
秒表(Stopwatch)是移动开发中最经典的入门项目之一。看似简单,实则涵盖了定时器管理、状态控制、列表渲染、数据处理等多个核心知识点。对于刚接触 HarmonyOS NEXT 的开发者来说,这是一个绝佳的进阶练手项目。
通过这个项目,你将学到:
| 知识点 | 应用场景 |
|---|---|
setInterval 定时器 |
实现毫秒级计时 |
状态变量 @State |
控制计时器的启动/暂停/重置 |
条件渲染 if |
动态显示运行状态 |
ForEach 列表渲染 |
展示计次记录 |
| 数据接口定义 | 封装圈数、单圈时间、总时间 |
| 算法逻辑 | 计算最快/最慢圈数 |
本文将从项目创建开始,逐步拆解每个功能模块的实现,最终完成一个功能完整、界面美观的秒表应用。
二、项目创建与配置
2.1 新建项目
打开 DevEco Studio,选择 File → New → Create Project:
- 选择 Application → Empty Ability 模板
- 填写项目信息:
- Project name:MyApplication
- Bundle name:com.example.myapplication
- Compile SDK:API 6.1.1(24)
- Model:Stage 模型
点击 Finish,等待项目初始化完成。
2.2 项目结构一览
MyApplication/
├── AppScope/
│ ├── app.json5 # 应用全局配置
│ └── resources/base/
│ ├── element/string.json # 应用名称
│ └── media/ # 应用图标
├── entry/
│ ├── src/main/
│ │ ├── ets/
│ │ │ ├── entryability/
│ │ │ │ └── EntryAbility.ets # 应用入口
│ │ │ └── pages/
│ │ │ └── Index.ets # 主页面 ⭐
│ │ ├── resources/
│ │ │ ├── base/
│ │ │ │ ├── element/ # 字符串、颜色
│ │ │ │ ├── media/ # 图片资源
│ │ │ │ └── profile/
│ │ │ │ └── main_pages.json
│ │ │ └── rawfile/
│ │ └── module.json5 # 模块配置
│ └── build-profile.json5
└── build-profile.json5 # 构建配置
三、功能需求分析
3.1 核心功能
一个完整的秒表应用需要具备以下功能:
| 功能 | 描述 |
|---|---|
| 开始计时 | 点击开始按钮,时间以 20ms 间隔更新 |
| 暂停计时 | 暂停后保留已计时间,可继续计时 |
| 重置计时 | 清零时间,清除所有计次记录 |
| 计次(Lap) | 记录当前单圈时间和累计总时间 |
| 最快/最慢标记 | 在计次列表中标记最快圈(🏆)和最慢圈(🐢) |
3.2 UI 设计
- 时间显示区:圆形表盘设计,居中显示
MM:SS.cs格式时间 - 控制按钮区:三个按钮横排(计次、开始/停止、重置)
- 计次列表区:滚动列表展示所有计次记录
- 状态提示:运行中显示红色"计时中",暂停时显示橙色"已暂停"
3.3 时间格式说明
采用 分:秒.厘秒 格式:
MM:SS.cs
│ │ │
│ │ └── 厘秒 (百分之一秒,00-99)
│ └───── 秒 (00-59)
└──────── 分钟 (00-99)
例如:02:34.56 表示 2 分 34 秒 56 厘秒。
四、数据模型设计
4.1 计次记录接口
每次点击"计次",需要记录以下信息:
interface LapRecord {
lapNumber: number // 圈数编号(1, 2, 3...)
lapTime: string // 单圈时间(如 "00:12.34")
totalTime: string // 累计总时间(如 "01:23.45")
}
4.2 页面状态变量
@Entry
@Component
struct Index {
// UI 显示状态
@State displayTime: string = '00:00.00' // 当前显示时间
@State isRunning: boolean = false // 是否正在计时
@State lapCount: number = 0 // 计次次数
@State laps: LapRecord[] = [] // 计次记录列表
// 最快/最慢圈索引
@State bestLapIndex: number = -1
@State worstLapIndex: number = -1
// 计时器相关(非响应式)
private startTime: number = 0 // 本次启动时间戳
private elapsedBeforePause: number = 0 // 暂停前累计时间
private lastLapTime: number = 0 // 上次计次时的总时间
private timerId: number = -1 // 定时器 ID
}
设计要点:
@State装饰的变量变化会触发 UI 刷新- 计时器内部变量不需要响应式,用
private声明 - 暂停后再次启动,需要累加之前的时间,所以用
elapsedBeforePause保存
五、核心逻辑实现
5.1 时间格式化
将毫秒转换为 MM:SS.cs 格式:
formatTime(ms: number): string {
const totalCs = Math.floor(ms / 10) // 转为厘秒
const minutes = Math.floor(totalCs / 6000) // 分钟
const seconds = Math.floor((totalCs % 6000) / 100) // 秒
const centiseconds = totalCs % 100 // 厘秒
return `${this.pad(minutes)}:${this.pad(seconds)}.${this.pad(centiseconds)}`
}
pad(n: number): string {
return n < 10 ? '0' + n : '' + n // 补零
}
计算示例:
| 毫秒 (ms) | 厘秒 (cs) | 分钟 | 秒 | 厘秒 | 输出 |
|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 00:00.00 |
| 1234 | 123 | 0 | 1 | 23 | 00:01.23 |
| 61789 | 6178 | 1 | 1 | 78 | 01:01.78 |
| 3600000 | 360000 | 60 | 0 | 0 | 60:00.00 |
5.2 开始计时
start(): void {
if (this.isRunning) return // 防止重复启动
this.startTime = Date.now() // 记录启动时间
this.isRunning = true
this.lastLapTime = this.elapsedBeforePause // 记录上次计次时间点
// 每 20ms 更新一次显示
this.timerId = setInterval(() => {
const now = Date.now()
const elapsed = this.elapsedBeforePause + (now - this.startTime)
this.displayTime = this.formatTime(elapsed)
}, 20)
}
为什么要记录 elapsedBeforePause?
如果用户点击"暂停"后再点击"开始",我们不应该从零开始计时,而是继续之前的累计时间。
时间线:
├── 开始 ──────────┤ 暂停 ├── 继续 ────────┤
5000ms 3000ms
总时间 = 5000 + 3000 = 8000ms
5.3 暂停计时
stop(): void {
if (!this.isRunning) return
clearInterval(this.timerId) // 清除定时器
this.elapsedBeforePause += Date.now() - this.startTime // 累加时间
this.isRunning = false
}
5.4 重置计时
reset(): void {
clearInterval(this.timerId) // 清除定时器
// 重置所有状态
this.isRunning = false
this.displayTime = '00:00.00'
this.elapsedBeforePause = 0
this.lastLapTime = 0
this.lapCount = 0
this.laps = []
this.bestLapIndex = -1
this.worstLapIndex = -1
}
⚠️ 注意:必须在组件销毁时清除定时器,否则会造成内存泄漏:
aboutToDisappear(): void {
if (this.timerId !== -1) {
clearInterval(this.timerId)
}
}
5.5 计次功能
lap(): void {
if (!this.isRunning) return // 必须在计时中才能计次
const now = Date.now()
const totalElapsed = this.elapsedBeforePause + (now - this.startTime)
const thisLapMs = totalElapsed - this.lastLapTime // 单圈时间 = 总时间 - 上次计次时间
this.lastLapTime = totalElapsed
this.lapCount++
const record: LapRecord = {
lapNumber: this.lapCount,
lapTime: this.formatTime(thisLapMs),
totalTime: this.formatTime(totalElapsed)
}
// 新记录插入数组开头(最新的在上面)
this.laps = [record, ...this.laps]
// 更新最快/最慢标记
this.updateBestWorst()
}
计算示例:
| 圈数 | 总时间 | 单圈时间 |
|---|---|---|
| 第1圈 | 00:12.34 | 00:12.34 |
| 第2圈 | 00:25.67 | 00:13.33 (25.67 - 12.34) |
| 第3圈 | 00:38.90 | 00:13.23 (38.90 - 25.67) |
5.6 计算最快/最慢圈
updateBestWorst(): void {
let bestIdx = 0
let worstIdx = 0
const lapTimes = this.laps.map(l => this.parseMs(l.lapTime))
for (let i = 1; i < lapTimes.length; i++) {
if (lapTimes[i] < lapTimes[bestIdx]) bestIdx = i
if (lapTimes[i] > lapTimes[worstIdx]) worstIdx = i
}
this.bestLapIndex = bestIdx
this.worstLapIndex = worstIdx
}
// 将时间字符串转回毫秒(用于比较)
parseMs(time: string): number {
const parts = time.split(/[:.]/) // ["00", "12", "34"]
const m = parseInt(parts[0]) * 60000 // 分钟转毫秒
const s = parseInt(parts[1]) * 1000 // 秒转毫秒
const cs = parseInt(parts[2]) * 10 // 厘秒转毫秒
return m + s + cs
}
六、界面实现
6.1 整体布局结构
build() {
Column() {
Scroll() { // 可滚动容器
Column() {
// 标题
Text('⏱ 秒表')
// 时间显示区(圆形表盘)
Stack() { ... }
// 控制按钮区
Row() { ... }
// 计次列表区
if (this.laps.length > 0) { ... }
else { 空状态提示 }
}
}
}
.width('100%')
.height('100%')
.backgroundColor('#ECEFF1') // 灰色背景
}
6.2 圆形表盘设计
使用 Stack 层叠容器实现同心圆效果:
Stack() {
// 外圈(灰色背景)
Circle()
.width(240)
.height(240)
.fill('#ECEFF1')
// 内圈(白色,带阴影)
Circle()
.width(210)
.height(210)
.fill(Color.White)
.shadow({ radius: 6, color: '#33000000', offsetY: 3 })
// 时间文字 + 状态
Column() {
Text(this.displayTime)
.fontSize(46)
.fontWeight(FontWeight.Bold)
.fontColor('#263238')
.fontFamily('Courier New') // 等宽字体
// 运行状态指示
if (this.isRunning) {
Row() {
Circle()
.width(8)
.height(8)
.fill('#F44336') // 红色圆点
.margin({ right: 6 })
Text('计时中')
.fontSize(13)
.fontColor('#F44336')
}
.margin({ top: 4 })
} else if (this.elapsedBeforePause > 0) {
Text('已暂停')
.fontSize(13)
.fontColor('#FF9800') // 橙色
.margin({ top: 4 })
}
}
.alignItems(HorizontalAlign.Center)
}
.width(240)
.height(240)
.margin({ top: 20 })
6.3 控制按钮区
三个按钮水平排列,中间的"开始/停止"按钮最大:
Row() {
// 计次按钮
Button('⏱ 计次')
.width(90)
.height(44)
.backgroundColor('#78909C')
.borderRadius(22)
.fontSize(15)
.onClick(() => this.lap())
// 开始/停止按钮(动态切换)
Button(this.isRunning ? '⏹ 停止' : '▶ 开始')
.width(130)
.height(52)
.backgroundColor(this.isRunning ? '#F44336' : '#4CAF50') // 红色停止/绿色开始
.borderRadius(26)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.margin({ left: 16, right: 16 })
.onClick(() => {
if (this.isRunning) {
this.stop()
} else {
this.start()
}
})
// 重置按钮
Button('↺ 重置')
.width(90)
.height(44)
.backgroundColor('#78909C')
.borderRadius(22)
.fontSize(15)
.onClick(() => this.reset())
}
.margin({ top: 24 })
6.4 计次列表
使用 ForEach 渲染列表:
if (this.laps.length > 0) {
Column() {
// 标题
Row() {
Text(`计次记录 (${this.laps.length})`)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#37474F')
}
.width('100%')
.padding({ bottom: 8 })
// 表头
Row() {
Text('圈数').fontSize(12).fontColor('#999').width(60)
Text('单圈时间').fontSize(12).fontColor('#999').width(100)
Text('总时间').fontSize(12).fontColor('#999').width(100)
}
.width('100%')
.padding({ left: 8, right: 8, bottom: 4 })
// 列表项
ForEach(this.laps, (lap: LapRecord, index: number) => {
Row() {
// 圈数 + 标记
Row() {
if (index === this.bestLapIndex && this.laps.length > 1) {
Text('🏆').fontSize(14).margin({ right: 2 })
} else if (index === this.worstLapIndex && this.laps.length > 1) {
Text('🐢').fontSize(14).margin({ right: 2 })
}
Text(`第 ${lap.lapNumber} 圈`)
.fontSize(14)
.fontColor('#37474F')
}
.width(60)
Text(lap.lapTime)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1976D2')
.width(100)
Text(lap.totalTime)
.fontSize(14)
.fontColor('#666')
.width(100)
}
.width('100%')
.padding({ left: 8, right: 8, top: 8, bottom: 8 })
.backgroundColor(index % 2 === 0 ? '#FFFFFF' : '#FAFAFA') // 斑马纹
.borderRadius(6)
.margin({ bottom: 2 })
}, (lap: LapRecord, index: number) => lap.lapNumber.toString() + index)
}
.width('100%')
.padding(16)
.backgroundColor(Color.White)
.borderRadius(12)
.shadow({ radius: 4, color: '#1A000000', offsetY: 2 })
.margin({ top: 24, left: 16, right: 16, bottom: 40 })
}
6.5 空状态提示
没有计次记录时显示引导文字:
else {
Column() {
Text('👆')
.fontSize(36)
Text('点击「开始」启动计时\n点击「计次」记录单圈时间')
.fontSize(14)
.fontColor('#BDBDBD')
.textAlign(TextAlign.Center)
.margin({ top: 8 })
.lineHeight(22)
}
.margin({ top: 40, bottom: 40 })
}
七、完整代码
Index.ets 完整源码
interface LapRecord {
lapNumber: number
lapTime: string
totalTime: string
}
@Entry
@Component
struct Index {
@State displayTime: string = '00:00.00'
@State isRunning: boolean = false
@State lapCount: number = 0
@State laps: LapRecord[] = []
@State bestLapIndex: number = -1
@State worstLapIndex: number = -1
private startTime: number = 0
private elapsedBeforePause: number = 0
private lastLapTime: number = 0
private timerId: number = -1
formatTime(ms: number): string {
const totalCs = Math.floor(ms / 10)
const minutes = Math.floor(totalCs / 6000)
const seconds = Math.floor((totalCs % 6000) / 100)
const centiseconds = totalCs % 100
return `${this.pad(minutes)}:${this.pad(seconds)}.${this.pad(centiseconds)}`
}
pad(n: number): string {
return n < 10 ? '0' + n : '' + n
}
start(): void {
if (this.isRunning) return
this.startTime = Date.now()
this.isRunning = true
this.lastLapTime = this.elapsedBeforePause
this.timerId = setInterval(() => {
const now = Date.now()
const elapsed = this.elapsedBeforePause + (now - this.startTime)
this.displayTime = this.formatTime(elapsed)
}, 20)
}
stop(): void {
if (!this.isRunning) return
clearInterval(this.timerId)
this.elapsedBeforePause += Date.now() - this.startTime
this.isRunning = false
}
reset(): void {
clearInterval(this.timerId)
this.isRunning = false
this.displayTime = '00:00.00'
this.elapsedBeforePause = 0
this.lastLapTime = 0
this.lapCount = 0
this.laps = []
this.bestLapIndex = -1
this.worstLapIndex = -1
}
lap(): void {
if (!this.isRunning) return
const now = Date.now()
const totalElapsed = this.elapsedBeforePause + (now - this.startTime)
const thisLapMs = totalElapsed - this.lastLapTime
this.lastLapTime = totalElapsed
this.lapCount++
const record: LapRecord = {
lapNumber: this.lapCount,
lapTime: this.formatTime(thisLapMs),
totalTime: this.formatTime(totalElapsed)
}
this.laps = [record, ...this.laps]
this.updateBestWorst()
}
updateBestWorst(): void {
let bestIdx = 0
let worstIdx = 0
const lapTimes = this.laps.map(l => this.parseMs(l.lapTime))
for (let i = 1; i < lapTimes.length; i++) {
if (lapTimes[i] < lapTimes[bestIdx]) bestIdx = i
if (lapTimes[i] > lapTimes[worstIdx]) worstIdx = i
}
this.bestLapIndex = bestIdx
this.worstLapIndex = worstIdx
}
parseMs(time: string): number {
const parts = time.split(/[:.]/)
const m = parseInt(parts[0]) * 60000
const s = parseInt(parts[1]) * 1000
const cs = parseInt(parts[2]) * 10
return m + s + cs
}
aboutToDisappear(): void {
if (this.timerId !== -1) {
clearInterval(this.timerId)
}
}
build() {
Column() {
Scroll() {
Column() {
Text('⏱ 秒表')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor('#37474F')
.margin({ top: 36 })
Stack() {
Circle().width(240).height(240).fill('#ECEFF1')
Circle().width(210).height(210).fill(Color.White)
.shadow({ radius: 6, color: '#33000000', offsetY: 3 })
Column() {
Text(this.displayTime)
.fontSize(46).fontWeight(FontWeight.Bold)
.fontColor('#263238').fontFamily('Courier New')
if (this.isRunning) {
Row() {
Circle().width(8).height(8).fill('#F44336').margin({ right: 6 })
Text('计时中').fontSize(13).fontColor('#F44336')
}.margin({ top: 4 })
} else if (this.elapsedBeforePause > 0) {
Text('已暂停').fontSize(13).fontColor('#FF9800').margin({ top: 4 })
}
}.alignItems(HorizontalAlign.Center)
}.width(240).height(240).margin({ top: 20 })
Row() {
Button('⏱ 计次').width(90).height(44)
.backgroundColor('#78909C').borderRadius(22).fontSize(15)
.onClick(() => this.lap())
Button(this.isRunning ? '⏹ 停止' : '▶ 开始')
.width(130).height(52)
.backgroundColor(this.isRunning ? '#F44336' : '#4CAF50')
.borderRadius(26).fontSize(17).fontWeight(FontWeight.Bold)
.margin({ left: 16, right: 16 })
.onClick(() => { this.isRunning ? this.stop() : this.start() })
Button('↺ 重置').width(90).height(44)
.backgroundColor('#78909C').borderRadius(22).fontSize(15)
.onClick(() => this.reset())
}.margin({ top: 24 })
if (this.laps.length > 0) {
Column() {
Row() {
Text(`计次记录 (${this.laps.length})`)
.fontSize(16).fontWeight(FontWeight.Medium).fontColor('#37474F')
}.width('100%').padding({ bottom: 8 })
Row() {
Text('圈数').fontSize(12).fontColor('#999').width(60)
Text('单圈时间').fontSize(12).fontColor('#999').width(100)
Text('总时间').fontSize(12).fontColor('#999').width(100)
}.width('100%').padding({ left: 8, right: 8, bottom: 4 })
ForEach(this.laps, (lap: LapRecord, index: number) => {
Row() {
Row() {
if (index === this.bestLapIndex && this.laps.length > 1) {
Text('🏆').fontSize(14).margin({ right: 2 })
} else if (index === this.worstLapIndex && this.laps.length > 1) {
Text('🐢').fontSize(14).margin({ right: 2 })
}
Text(`第 ${lap.lapNumber} 圈`).fontSize(14).fontColor('#37474F')
}.width(60)
Text(lap.lapTime).fontSize(15).fontWeight(FontWeight.Bold)
.fontColor('#1976D2').width(100)
Text(lap.totalTime).fontSize(14).fontColor('#666').width(100)
}
.width('100%').padding({ left: 8, right: 8, top: 8, bottom: 8 })
.backgroundColor(index % 2 === 0 ? '#FFFFFF' : '#FAFAFA')
.borderRadius(6).margin({ bottom: 2 })
}, (lap: LapRecord, index: number) => lap.lapNumber.toString() + index)
}
.width('100%').padding(16).backgroundColor(Color.White)
.borderRadius(12).shadow({ radius: 4, color: '#1A000000', offsetY: 2 })
.margin({ top: 24, left: 16, right: 16, bottom: 40 })
} else {
Column() {
Text('👆').fontSize(36)
Text('点击「开始」启动计时\n点击「计次」记录单圈时间')
.fontSize(14).fontColor('#BDBDBD')
.textAlign(TextAlign.Center).margin({ top: 8 }).lineHeight(22)
}.margin({ top: 40, bottom: 40 })
}
}.width('100%').alignItems(HorizontalAlign.Center)
}.width('100%').height('100%')
}
.width('100%').height('100%')
.backgroundColor('#ECEFF1')
}
}
八、开发踩坑记录
8.1 踩坑一:定时器内存泄漏
问题:当用户快速切换页面或关闭应用时,定时器仍在运行,导致内存泄漏。
解决:在 aboutToDisappear() 生命周期中清除定时器:
aboutToDisappear(): void {
if (this.timerId !== -1) {
clearInterval(this.timerId)
}
}
8.2 踩坑二:暂停后时间不准确
问题:简单的计时器实现会在暂停后丢失时间。
// ❌ 错误做法
start(): void {
this.timerId = setInterval(() => {
this.elapsed += 20
this.displayTime = this.formatTime(this.elapsed)
}, 20)
}
原因:setInterval 不精确,每次执行间隔可能超过 20ms。
解决:使用时间戳差值计算:
// ✅ 正确做法
start(): void {
this.startTime = Date.now() // 记录启动时间
this.timerId = setInterval(() => {
const now = Date.now()
const elapsed = this.elapsedBeforePause + (now - this.startTime)
this.displayTime = this.formatTime(elapsed)
}, 20)
}
8.3 踩坑三:ForEach key 重复
问题:使用 lap.lapNumber.toString() 作为 key,当重置后重新计次时,key 会重复(都从 “1” 开始)。
解决:将 key 与 index 组合:
ForEach(this.laps, (lap: LapRecord, index: number) => {
// ...
}, (lap: LapRecord, index: number) => lap.lapNumber.toString() + index)
8.4 踩坑四:按钮状态同步
问题:点击"计次"按钮时,如果计时器未启动,没有反馈。
解决:添加条件判断:
lap(): void {
if (!this.isRunning) return // 未启动时不响应
// ...
}
九、运行与测试
9.1 运行应用
- 连接模拟器或真机
- 点击 DevEco Studio 的 Run 按钮
- 等待编译安装完成

9.2 测试用例
| 操作步骤 | 预期结果 | 测试结果 |
|---|---|---|
| 启动应用 | 显示 00:00.00,空状态提示 | ✅ |
| 点击"开始" | 时间开始计时,按钮变"停止",显示"计时中" | ✅ |
| 等待 5 秒后点击"计次" | 显示第1圈记录,单圈时间约 00:05.00 | ✅ |
| 再等待 3 秒后点击"计次" | 显示第2圈记录,单圈时间约 00:03.00 | ✅ |
| 点击"停止" | 时间暂停,显示"已暂停" | ✅ |
| 点击"开始" | 从暂停时间继续计时 | ✅ |
| 点击"重置" | 所有数据清零 | ✅ |
| 多圈计次后 | 最快圈显示🏆,最慢圈显示🐢 | ✅ |
十、总结与展望
10.1 技术要点回顾
通过这个秒表项目,我们实践了:
| 技术点 | 应用场景 |
|---|---|
setInterval 定时器 |
实现毫秒级计时更新 |
| 时间戳计算 | 精确计时,支持暂停/继续 |
@State 状态管理 |
驱动 UI 响应式更新 |
Stack 层叠布局 |
实现同心圆表盘效果 |
ForEach 列表渲染 |
展示计次记录 |
条件渲染 if |
动态显示状态和列表 |
| 生命周期钩子 | 防止定时器内存泄漏 |
| 数据处理算法 | 计算最快/最慢圈数 |
10.2 功能扩展方向
如果你想继续深化,可以尝试:
- 本地存储:使用
Preferences保存历史记录 - 振动反馈:计次时触发振动(需权限)
- 主题切换:支持深色模式
- 语音播报:使用 TTS 读出圈数和时间
- 导出数据:生成 CSV/JSON 格式的计次记录
- 比较模式:对比两次计时的差异
附录:项目配置
app.json5
{
"app": {
"bundleName": "com.example.myapplication",
"vendor": "example",
"versionCode": 1000000,
"versionName": "1.0.0"
}
}
string.json
{
"string": [
{ "name": "module_desc", "value": "秒表计时模块" },
{ "name": "EntryAbility_desc", "value": "秒表计时器" },
{ "name": "EntryAbility_label", "value": "秒表" }
]
}
开发环境:DevEco Studio + HarmonyOS NEXT API 24
如果这篇文章对你有帮助,欢迎点赞、收藏、评论!有问题欢迎留言讨论~
更多推荐
所有评论(0)