一、數據模型:構建運動記錄的數字骨架
代碼通過RunRecord
接口定義了跑步數據的核心結構:
interface RunRecord {id: string; // 記錄唯一標識date: Date; // 跑步日期distance: number; // 距離(公里)duration: number; // 時長(分鐘)pace: number; // 配速(分鐘/公里)
}
這一模型以"距離-時長-配速"為核心維度,通過id
和date
建立時間軸索引。在實際跑步場景中,當用戶點擊"結束跑步"時,系統會基于實時數據生成RunRecord
對象并添加到runRecords
數組中:
const newRecord: RunRecord = {id: Date.now().toString(),date: new Date(),distance: this.currentDistance,duration: this.currentDuration,pace: this.currentPace
};
this.runRecords = [newRecord, ...this.runRecords];
這種設計遵循了"最小必要數據"原則,既滿足基礎運動分析需求,又降低了數據存儲與計算的復雜度。
二、狀態管理:實現運動數據的實時響應
應用通過@State
裝飾器管理五大核心狀態,構建起數據流轉的中樞系統:
isRunning
:標記跑步狀態(進行中/已結束),控制界面按鈕與數據展示邏輯currentDistance/duration/pace
:實時跑步數據,通過定時器模擬GPS更新showHistory
:控制歷史記錄列表的展開/收起狀態
核心狀態更新邏輯體現在startRun
與endRun
方法中。當用戶點擊"開始跑步"時,系統啟動定時器以100ms為周期更新數據:
this.intervalId = setInterval(() => {this.currentDistance += 0.01; // 模擬每100ms增加10米this.currentDuration += 0.1; // 模擬每100ms增加0.1秒if (this.currentDistance > 0) {this.currentPace = this.currentDuration / this.currentDistance / 60;}
}, 100);
而endRun
方法則負責清除定時器、保存記錄并重置狀態,形成"數據采集-存儲-重置"的閉環。
三、UI組件:打造直觀的運動數據可視化體驗
應用采用"統計頭部-數據卡片-歷史列表"的三層布局,通過ArkTS的聲明式UI特性實現動態渲染:
- 統計頭部組件(StatsHeader)
采用三欄式布局展示總跑步次數、總距離與平均配速,通過reduce
方法對歷史記錄進行聚合計算:
Text(`${this.runRecords.reduce((sum, r) => sum + r.distance, 0).toFixed(1)}km`)
數據展示遵循"數值+單位"的層級結構,大號字體突出核心數字,小號字體標注單位與說明,符合用戶"先看結果再辨含義"的認知習慣。
- 跑步數據卡片(RunDataCard)
通過isRunning
狀態動態切換顯示邏輯:跑步中時突出距離與時長數據,搭配配速與時長的分欄展示;跑步結束后則展示今日匯總數據。按鈕設計采用綠色(開始)與紅色(結束)的高對比度配色,強化操作反饋。 - 歷史記錄列表(HistoryList)
通過showHistory
狀態控制顯示/隱藏,使用ForEach
循環渲染歷史記錄,每條記錄包含日期、時長、距離與配速信息。當記錄為空時顯示空狀態提示,提升用戶體驗的完整性。
四、核心算法:從原始數據到運動洞察
代碼中包含三個關鍵數據處理函數,將原始運動數據轉化為可解讀的運動指標:
formatTime
:將秒數轉換為"分:秒"格式(如123
秒轉為2:03
),便于用戶快速理解運動時長getTodayDistance/duration/pace
:通過日期篩選與數組聚合,計算今日運動數據,支持用戶查看短期運動趨勢formatDate
:將Date對象轉換為"月/日"格式(如6/15
),簡化歷史記錄的時間展示
以getTodayPace
為例,其核心邏輯是通過篩選今日記錄并計算平均配速:
const totalDistance = this.getTodayDistance();
const totalDuration = this.getTodayDuration();
if (totalDistance === 0) return 0;
return totalDuration / totalDistance / 60;
五、附:代碼
import promptAction from '@ohos.promptAction';// 跑步記錄接口
interface RunRecord {id: string;date: Date;distance: number; // 公里duration: number; // 分鐘pace: number; // 配速(分鐘/公里)
}
@Entry
@Component
struct Index {@State runRecords: RunRecord[] = []; // 跑步記錄列表@State isRunning: boolean = false; // 是否正在跑步@State currentDistance: number = 0; // 當前跑步距離@State currentDuration: number = 0; // 當前跑步時長@State currentPace: number = 0; // 當前配速@State showHistory: boolean = false; // 是否顯示歷史記錄private intervalId: number = -1; // 定時器ID// 開始跑步private startRun() {this.isRunning = true;this.currentDistance = 0;this.currentDuration = 0;this.currentPace = 0;// 模擬GPS定位更新this.intervalId = setInterval(() => {this.currentDistance += 0.01; // 模擬每100ms增加10米this.currentDuration += 0.1; // 模擬每100ms增加0.1秒// 更新配速if (this.currentDistance > 0) {this.currentPace = this.currentDuration / this.currentDistance / 60;}}, 100);}// 結束跑步private endRun() {clearInterval(this.intervalId);// 創建新記錄const newRecord: RunRecord = {id: Date.now().toString(),date: new Date(),distance: this.currentDistance,duration: this.currentDuration,pace: this.currentPace};// 添加到記錄列表this.runRecords = [newRecord, ...this.runRecords];// 重置狀態this.isRunning = false;this.currentDistance = 0;this.currentDuration = 0;this.currentPace = 0;promptAction.showToast({ message: '跑步記錄已保存' });}// 格式化時間為分:秒private formatTime(seconds: number): string {const minutes = Math.floor(seconds / 60);const secs = Math.floor(seconds % 60);return `${minutes}:${secs < 10 ? '0' : ''}${secs}`;}// 獲取今日跑步距離private getTodayDistance(): number {const today = new Date();today.setHours(0, 0, 0, 0);const todayRuns = this.runRecords.filter(record => {const recordDate = new Date(record.date);recordDate.setHours(0, 0, 0, 0);return recordDate.getTime() === today.getTime();});return todayRuns.reduce((sum, record) => sum + record.distance, 0);}// 獲取今日跑步時長private getTodayDuration(): number {const today = new Date();today.setHours(0, 0, 0, 0);const todayRuns = this.runRecords.filter(record => {const recordDate = new Date(record.date);recordDate.setHours(0, 0, 0, 0);return recordDate.getTime() === today.getTime();});return todayRuns.reduce((sum, record) => sum + record.duration, 0);}// 獲取今日平均配速private getTodayPace(): number {const totalDistance = this.getTodayDistance();const totalDuration = this.getTodayDuration();if (totalDistance === 0) return 0;return totalDuration / totalDistance / 60;}// 格式化日期private formatDate(date: Date): string {return `${date.getMonth() + 1}/${date.getDate()}`;}// 頭部統計組件@BuilderStatsHeader() {Column() {Text('跑步統計').fontSize(18).fontWeight(FontWeight.Bold).margin({ bottom: 15 })Row() {Column() {Text(`${this.runRecords.length}`).fontSize(24).fontWeight(FontWeight.Bold)Text('總次數').fontSize(12).fontColor('#888').margin({top: 5})}.width('33%')Column() {Text(`${this.runRecords.reduce((sum, r) => sum + r.distance, 0).toFixed(1)}km`).fontSize(24).fontWeight(FontWeight.Bold)Text('總距離').fontSize(12).fontColor('#888').margin({top: 5})}.width('33%')Column() {Text(`${(this.runRecords.reduce((sum, r) => sum + r.pace, 0) / this.runRecords.length || 0).toFixed(2)}min/km`).fontSize(24).fontWeight(FontWeight.Bold)Text('平均配速').fontSize(12).fontColor('#888').margin({top: 5})}.width('33%')}.width('100%')}.width('100%').padding(15).backgroundColor('#F8F9FC').borderRadius(12)}// 跑步數據卡片@BuilderRunDataCard() {Column() {Text(this.isRunning ? '跑步中' : '今日跑步數據').fontSize(18).fontWeight(FontWeight.Bold).margin({ bottom: 25 })if (this.isRunning) {// 跑步中數據顯示Column() {Text(`${this.currentDistance.toFixed(2)}km`).fontSize(42).fontWeight(FontWeight.Bold).margin({ bottom: 15 })Text(`${this.formatTime(this.currentDuration)}`).fontSize(24).margin({ bottom: 25 })Row() {Column() {Text(`${this.currentPace.toFixed(2)}min/km`).fontSize(16).fontWeight(FontWeight.Bold)Text('配速').fontSize(12).fontColor('#888').margin({top: 5})}.width('50%')Column() {Text(`${this.formatTime(this.currentDuration)}`).fontSize(16).fontWeight(FontWeight.Bold)Text('時長').fontSize(12).fontColor('#888').margin({top: 5})}.width('50%')}.width('100%')}.width('100%').alignItems(HorizontalAlign.Center).margin({ bottom: 25 })} else {// 跑步后數據顯示Row() {Column() {Text(`${this.getTodayDistance().toFixed(2)}km`).fontSize(24).fontWeight(FontWeight.Bold)Text('距離').fontSize(12).fontColor('#888').margin({top: 5})}.width('33%')Column() {Text(`${this.formatTime(this.getTodayDuration())}`).fontSize(24).fontWeight(FontWeight.Bold)Text('時長').fontSize(12).fontColor('#888').margin({top: 5})}.width('33%')Column() {Text(`${this.getTodayPace().toFixed(2)}min/km`).fontSize(24).fontWeight(FontWeight.Bold)Text('配速').fontSize(12).fontColor('#888').margin({top: 5})}.width('33%')}.width('100%').margin({ bottom: 25 })}if (this.isRunning) {Button('結束跑步').width('100%').height(45).backgroundColor('#E53935').fontColor(Color.White).fontSize(16).borderRadius(8).onClick(() => this.endRun())} else {Button('開始跑步').width('100%').height(45).backgroundColor('#2E7D32').fontColor(Color.White).fontSize(16).borderRadius(8).onClick(() => this.startRun())}}.width('100%').padding(15).backgroundColor(Color.White).borderRadius(12).shadow({ radius: 3, color: '#0000001A' })}// 歷史記錄列表@BuilderHistoryList() {if (this.showHistory) {Column() {Text('跑步歷史').fontSize(16).fontWeight(FontWeight.Bold).margin({ bottom: 15 })if (this.runRecords.length === 0) {Text('暫無跑步記錄').fontSize(14).fontColor('#AAA').margin({ top: 40 })} else {List() {ForEach(this.runRecords, (record: RunRecord) => {ListItem() {Row() {Column() {Text(this.formatDate(record.date)).fontSize(14)Text(`${this.formatTime(record.duration)}`).fontSize(12).fontColor('#888').margin({top: 4})}.width('40%')Column() {Text(`${record.distance}km`).fontSize(14).fontWeight(FontWeight.Bold)Text(`${record.pace.toFixed(2)}min/km`).fontSize(12).fontColor('#888').margin({top: 4})}.width('60%')}.width('100%').padding(8)}})}}}.width('100%').padding(15).backgroundColor('#F8F9FC').borderRadius(12).layoutWeight(1)}}build() {Column() {// 統計頭部this.StatsHeader()// 跑步數據卡片this.RunDataCard()// 歷史記錄this.HistoryList()// 底部按鈕Button(this.showHistory ? '隱藏歷史' : '顯示歷史').width('100%').margin({ top: 15 }).height(40).fontSize(14).borderRadius(8).backgroundColor('#E0E0E0').fontColor('#333').onClick(() => {this.showHistory = !this.showHistory;})}.width('100%').height('100%').padding(12).backgroundColor('#FCFDFF')}
}