从零打造健康追踪APP:Preferences存储与Canvas图表实战
·
从零打造健康追踪APP:Preferences存储与Canvas图表实战
当数据持久化遇上可视化图表,HarmonyOS 开发者的进阶之路
写在前面
健康类 APP 是移动开发中最经典的练手项目之一——它涵盖了数据录入、持久化存储、图表展示、用户设置等完整的业务闭环。相比 Todo List,健康追踪更能体现"数据驱动"的开发理念。
本文分享使用 HarmonyOS NEXT 开发「个人健康助手」的完整过程,重点聚焦两个技术难点:
- Preferences 数据存储:如何设计一个优雅的存储管理器?
- Canvas 图表绘制:如何从零手写柱状图,不依赖第三方库?
一、项目功能一览
1.1 功能清单
📱 个人健康助手
│
├── 📊 首页仪表盘
│ ├── 问候语 + 日期显示
│ ├── 健康小贴士轮播(4秒自动切换)
│ ├── 步数/饮水/睡眠卡片(带进度条)
│ ├── 卡路里 + 心情卡片
│ └── 进度 = 实际值 / 目标值
│
├── 📝 数据录入
│ ├── 步数步进器(+/- 100步)
│ ├── 饮水步进器(+/- 50ml)
│ ├── 睡眠步进器(+/- 1小时)
│ ├── 卡路里步进器(+/- 50kcal)
│ ├── 心情选择器(5种 emoji)
│ ├── 备注输入
│ └── 保存/重置
│
├── 📈 统计图表
│ ├── 周导航(上一周/下一周)
│ ├── 步数柱状图(Canvas)
│ ├── 饮水+睡眠双柱对比图(Canvas)
│ ├── 周平均统计
│ └── 最佳步数日
│
└── ⚙️ 个人设置
├── 昵称修改
├── 目标值设置(步数/饮水/睡眠/热量)
├── 编辑/保存模式切换
├── 数据清除(带确认对话框)
└── 关于信息
1.2 技术栈
| 技术 | 用途 |
|---|---|
| Preferences | 轻量级本地存储 |
| Canvas 2D | 柱状图、双柱对比图 |
| @State / @Link | 响应式状态管理 |
| Tabs | 底部导航 |
| ForEach | 列表渲染 |
| AlertDialog | 确认对话框 |
二、数据模型:一切的基础
2.1 数据类型定义
// 每日健康记录
export interface DailyRecord {
date: string; // "2026-06-03"
steps: number; // 步数
water: number; // 饮水 ml
sleep: number; // 睡眠小时
calories: number; // 卡路里
mood: number; // 心情 1-5
note: string; // 备注
}
// 用户设置
export interface UserSettings {
userName: string;
stepGoal: number; // 默认 10000
waterGoal: number; // 默认 2000
sleepGoal: number; // 默认 8
calorieGoal: number; // 默认 2000
}
2.2 日期工具函数
处理日期是健康类 APP 的基本功:
// 获取今天日期字符串 "2026-06-03"
export function getTodayString(): string {
const now = new Date();
const y = now.getFullYear();
const m = (now.getMonth() + 1).toString().padStart(2, '0');
const d = now.getDate().toString().padStart(2, '0');
return `${y}-${m}-${d}`;
}
// 获取本周一的日期(周起始)
export function getWeekStart(dateStr: string): string {
const d = new Date(dateStr);
const day = d.getDay(); // 0=周日
const diff = day === 0 ? -6 : 1 - day;
d.setDate(d.getDate() + diff);
return formatDate(d);
}
// 获取一周7天日期数组
export function getWeekDates(startDate: string): string[] {
const start = new Date(startDate);
const dates: string[] = [];
for (let i = 0; i < 7; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
dates.push(formatDate(d));
}
return dates;
}
// 心情 emoji 映射
export function getMoodEmoji(level: number): string {
const emojis = ['😫', '😔', '😐', '😊', '😄'];
return emojis[Math.max(0, Math.min(4, level - 1))];
}
// 心情文字映射
export function getMoodText(level: number): string {
const texts = ['很差', '较差', '一般', '不错', '很棒'];
return texts[Math.max(0, Math.min(4, level - 1))];
}
三、Preferences 存储管理器
3.1 为什么需要管理器?
直接在页面中调用 Preferences API 会导致:
- 代码分散,难以维护
- 每次操作都要处理初始化逻辑
- 类型不安全,容易写错 key
封装成管理器的好处:
- 单例模式:全局共享同一实例
- 类型安全:强类型接口
- 统一错误处理:一处 catch,全局生效
3.2 管理器实现
import { preferences } from '@kit.ArkData';
import { DailyRecord, UserSettings, createDefaultRecord, createDefaultSettings } from '../model/HealthData';
const STORE_NAME = 'health_app_store';
const SETTINGS_KEY = 'user_settings';
const RECORDS_KEY = 'all_records_json';
class StorageManager {
private store_: preferences.Preferences | null = null;
private isReady_: boolean = false;
// 初始化(在 EntryAbility 中调用)
async init(context: Context): Promise<void> {
if (this.isReady_) return;
this.store_ = await preferences.getPreferences(context, STORE_NAME);
this.isReady_ = true;
}
// ===== 用户设置 =====
async saveSettings(settings: UserSettings): Promise<void> {
await this.checkReady();
await this.store_!.put(SETTINGS_KEY, JSON.stringify(settings));
await this.store_!.flush(); // 必须调用 flush 才会写入磁盘
}
async loadSettings(): Promise<UserSettings> {
await this.checkReady();
const val = await this.store_!.get(SETTINGS_KEY, '') as string;
return val === '' ? createDefaultSettings() : JSON.parse(val);
}
// ===== 每日记录 =====
async saveRecord(record: DailyRecord): Promise<void> {
await this.checkReady();
const records = await this.loadAllRecords();
// 查找同日记录,存在则覆盖
const idx = records.findIndex(r => r.date === record.date);
if (idx >= 0) {
records[idx] = record;
} else {
records.push(record);
}
await this.store_!.put(RECORDS_KEY, JSON.stringify(records));
await this.store_!.flush();
}
async loadRecord(date: string): Promise<DailyRecord | null> {
const records = await this.loadAllRecords();
return records.find(r => r.date === date) ?? null;
}
async loadAllRecords(): Promise<DailyRecord[]> {
await this.checkReady();
const val = await this.store_!.get(RECORDS_KEY, '') as string;
return val === '' ? [] : JSON.parse(val);
}
// 获取日期范围内的记录(用于图表)
async loadRecordsInRange(dates: string[]): Promise<DailyRecord[]> {
const all = await this.loadAllRecords();
return dates.map(d => all.find(r => r.date === d) ?? createDefaultRecord(d));
}
// 清除所有数据
async clearAll(): Promise<void> {
await this.checkReady();
await this.store_!.clear();
await this.store_!.flush();
}
private async checkReady(): Promise<void> {
if (!this.isReady_ || !this.store_) {
throw new Error('StorageManager not initialized. Call init() first.');
}
}
}
// 全局单例
const storageManager = new StorageManager();
export default storageManager;
3.3 初始化时机
在 EntryAbility.ets 中初始化:
import storageManager from '../utils/StorageManager';
export default class EntryAbility extends UIAbility {
async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> {
// 初始化存储管理器
await storageManager.init(this.context);
}
}
3.4 使用示例
// 加载设置
const settings = await storageManager.loadSettings();
// 保存记录
await storageManager.saveRecord({
date: getTodayString(),
steps: 8000,
water: 1500,
sleep: 7.5,
calories: 1800,
mood: 4,
note: '今天状态不错'
});
// 加载一周数据
const weekDates = getWeekDates(getWeekStart(getTodayString()));
const records = await storageManager.loadRecordsInRange(weekDates);
四、自定义组件:提升代码复用
4.1 HealthCard — 健康数据卡片
带进度条的数据卡片,用于首页仪表盘:
@Component
export struct HealthCard {
@Prop title: string = '';
@Prop value: string = '0';
@Prop unit: string = '';
@Prop progress: number = 0; // 0~1
@Prop icon: string = '📊';
@Prop color: Color = Color.Blue;
build() {
Column() {
Text(this.icon).fontSize(28)
Text(this.value)
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor(this.color)
Text(this.unit).fontSize(12).fontColor('#999')
// 进度条
Row() {
Row()
.width((this.progress * 100) + '%')
.height(4)
.backgroundColor(this.color)
.borderRadius(2)
}
.width('100%')
.height(4)
.backgroundColor('#E8E8E8')
.borderRadius(2)
.margin({ top: 8 })
}
.padding(12)
.backgroundColor(Color.White)
.borderRadius(12)
.shadow({ radius: 4, color: '#20000000', offsetY: 2 })
.alignItems(HorizontalAlign.Center)
}
}
用法:
HealthCard({
title: '步数',
value: '6,842',
unit: '步',
progress: 0.68, // 6842 / 10000
icon: '🏃',
color: Color.Blue
})
4.2 NumberStepper — 数值步进器
用于记录页的数值录入:
@Component
export struct NumberStepper {
@Prop label: string = '';
@Prop unit: string = '';
@Prop step: number = 1;
@Prop minValue: number = 0;
@Prop maxValue: number = 99999;
@Link value: number; // 双向绑定
build() {
Row() {
Text(this.label).fontSize(14).layoutWeight(1)
// 减号按钮
Button('-')
.width(36).height(36).borderRadius(18)
.fontSize(20).fontWeight(FontWeight.Bold)
.backgroundColor('#F0F0F0')
.fontColor(Color.Black)
.enabled(this.value > this.minValue)
.onClick(() => {
this.value = Math.max(this.minValue, this.value - this.step);
})
Text(this.value.toString())
.fontSize(20).fontWeight(FontWeight.Bold)
.width(60).textAlign(TextAlign.Center)
Text(this.unit).fontSize(12).fontColor('#999').margin({ right: 8 })
// 加号按钮
Button('+')
.width(36).height(36).borderRadius(18)
.fontSize(20).fontWeight(FontWeight.Bold)
.backgroundColor($r('app.color.primary'))
.fontColor(Color.White)
.enabled(this.value < this.maxValue)
.onClick(() => {
this.value = Math.min(this.maxValue, this.value + this.step);
})
}
.padding(12)
.backgroundColor(Color.White)
.borderRadius(12)
}
}
用法:
NumberStepper({
label: '今日步数',
unit: '步',
step: 100,
minValue: 0,
maxValue: 99999,
value: this.steps // @Link 双向绑定
})
4.3 MoodSelector — 心情选择器
5 种 emoji 心情,点击选择:
@Component
export struct MoodSelector {
private moods: number[] = [1, 2, 3, 4, 5];
@Link currentMood: number;
build() {
Row() {
ForEach(this.moods, (level: number) => {
Column() {
Text(getMoodEmoji(level))
.fontSize(32)
.opacity(level === this.currentMood ? 1.0 : 0.4)
Text(getMoodText(level))
.fontSize(10)
.fontColor(level === this.currentMood
? $r('app.color.primary')
: $r('app.color.text_secondary'))
}
.padding(6)
.backgroundColor(level === this.currentMood ? '#1A007AFF' : Color.Transparent)
.borderRadius(12)
.onClick(() => { this.currentMood = level; })
})
}
.justifyContent(FlexAlign.SpaceAround)
.backgroundColor(Color.White)
.borderRadius(12)
}
}
4.4 HealthTipBanner — 健康贴士轮播
自动轮播的健康小贴士:
@Component
export struct HealthTipBanner {
@State currentIndex: number = 0;
private tips: string[] = [
'☀️ 每天喝够8杯水,保持身体活力',
'🏃 每日步行10000步,有益心血管健康',
'😴 保证7-8小时睡眠,提高免疫力',
'🥗 三餐规律,多吃蔬菜水果',
'🧘 适当放松,保持良好心态'
];
private intervalId: number = -1;
aboutToAppear(): void {
this.intervalId = setInterval(() => {
this.currentIndex = (this.currentIndex + 1) % this.tips.length;
}, 4000);
}
aboutToDisappear(): void {
if (this.intervalId >= 0) {
clearInterval(this.intervalId);
}
}
build() {
Row() {
Text(this.tips[this.currentIndex])
.fontSize(14).fontColor(Color.White)
.layoutWeight(1)
// 指示点
Row() {
ForEach(this.tips, (_: string, idx: number) => {
Circle()
.width(6).height(6)
.fill(idx === this.currentIndex ? Color.White : '#66FFFFFF')
.margin({ left: 3, right: 3 })
})
}
}
.height(52)
.backgroundColor($r('app.color.primary'))
.borderRadius(12)
.padding({ left: 12, right: 12 })
}
}
五、首页:数据仪表盘
5.1 页面布局
┌─────────────────────────────┐
│ 你好, 用户 │ 蓝色背景
│ 6月3日 星期二 ❤️ │
├─────────────────────────────┤
│ ☀️ 每天喝够8杯水... ●○○○○ │ 轮播条
├─────────────────────────────┤
│ 今日概览 │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ 🏃 │ │ 💧 │ │ 😴 │ │ 三列卡片
│ │6842 │ │1200 │ │ 7.5 │ │
│ │ 步 │ │ ml │ │小时 │ │
│ │███░ │ │████ │ │████░│ │
│ └─────┘ └─────┘ └─────┘ │
│ ┌────────┐ ┌────────┐ │
│ │ 🔥 │ │ 😊 │ │ 卡路里+心情
│ │ 1850 │ │ 不错 │ │
│ │ 千卡 │ │今日心情│ │
│ └────────┘ └────────┘ │
│ 💡 点击"记录"标签添加数据 │
└─────────────────────────────┘
5.2 数据加载
@Component
export struct HomePage {
@State todayRecord: DailyRecord | null = null;
@State settings: UserSettings | null = null;
aboutToAppear(): void {
this.loadData();
}
async loadData(): Promise<void> {
this.settings = await storageManager.loadSettings();
this.todayRecord = await storageManager.loadRecord(getTodayString());
}
}
5.3 进度计算
进度条显示目标达成率:
// 步数进度
progress: Math.min(1, todayRecord.steps / settings.stepGoal)
// 饮水进度
progress: Math.min(1, todayRecord.water / settings.waterGoal)
// 睡眠进度
progress: Math.min(1, todayRecord.sleep / settings.sleepGoal)
六、Canvas 图表:从零手写柱状图
6.1 图表布局规划
┌──────────────────────────────────┐
│ │ totalH = 190
│ 10k ┼─────────────────────── │ padT = 20
│ │ ██ │
│ 5k ┼─ ██ ─██─ ██ ─██ ─██─ │ chartH = 140
│ │ ██ ██ ██ ██ ██ │
│ 0 ┼─────────────────────── │ padB = 30
│ ─ 二 三 四 五 六 日 │
│ padL padR
│ 40 10
└──────────────────────────────────┘
totalW = 350
6.2 步数柱状图绘制
drawStepChart(): void {
const ctx = this.barCtx;
const totalW = 350, totalH = 190;
const padL = 40, padR = 10, padT = 20, padB = 30;
const chartW = totalW - padL - padR; // 300
const chartH = totalH - padT - padB; // 140
ctx.clearRect(0, 0, totalW, totalH);
// 1. 计算最大值
let maxVal = 0;
for (const r of this.records) {
if (r.steps > maxVal) maxVal = r.steps;
}
if (maxVal === 0) maxVal = 10000;
// 2. 绘制网格线
const gridLines = 4;
for (let i = 0; i <= gridLines; i++) {
const yy = padT + (chartH / gridLines) * i;
ctx.strokeStyle = '#E8E8E8';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(padL, yy);
ctx.lineTo(totalW - padR, yy);
ctx.stroke();
// Y轴标签
const labelVal = Math.round(maxVal * (1 - i / gridLines));
ctx.fillStyle = '#666666';
ctx.font = '10px sans-serif';
ctx.textAlign = 'right';
ctx.fillText(this.formatShort(labelVal), padL - 6, yy + 4);
}
// 3. 绘制柱状图
const barCount = this.records.length; // 7天
const totalGap = chartW * 0.3; // 30% 间隔
const gap = totalGap / (barCount + 1);
const barW = (chartW - totalGap) / barCount;
for (let i = 0; i < barCount; i++) {
const record = this.records[i];
const barH = (record.steps / maxVal) * chartH;
const bx = padL + gap + i * (barW + gap);
const by = padT + chartH - barH;
// 柱体
ctx.fillStyle = '#007AFF';
ctx.fillRect(bx, by, barW, barH);
// 顶部数值
ctx.fillStyle = '#007AFF';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(this.formatShort(record.steps), bx + barW / 2, by - 4);
// X轴标签(星期)
ctx.fillStyle = '#666666';
ctx.fillText(getWeekDayName(this.weekDates[i]), bx + barW / 2, totalH - 6);
}
}
formatShort(val: number): string {
if (val >= 10000) return (val / 10000).toFixed(1) + 'k';
if (val >= 1000) return (val / 1000).toFixed(1) + 'k';
return val.toString();
}
6.3 饮水+睡眠双柱对比图
在同一图表中展示两组数据:
drawWaterChart(): void {
const ctx = this.waterCtx;
// ... 布局参数同上 ...
// 计算两组数据的最大值
let maxWater = 0, maxSleep = 0;
for (const r of this.records) {
if (r.water > maxWater) maxWater = r.water;
if (r.sleep > maxSleep) maxSleep = r.sleep;
}
if (maxWater === 0) maxWater = 2000;
if (maxSleep === 0) maxSleep = 10;
// 绘制柱状图
const groupW = (chartW - totalGap) / barCount;
const innerBarW = groupW * 0.35; // 每组两根柱
for (let i = 0; i < barCount; i++) {
const record = this.records[i];
const xBase = padL + gap + i * (groupW + gap);
// 饮水柱(绿色)
const waterH = (record.water / maxWater) * chartH;
ctx.fillStyle = '#34C759';
ctx.fillRect(xBase, padT + chartH - waterH, innerBarW, waterH);
// 睡眠柱(紫色)
const sleepH = (record.sleep / maxSleep) * chartH;
ctx.fillStyle = '#AF52DE';
ctx.fillRect(xBase + innerBarW + 2, padT + chartH - sleepH, innerBarW, sleepH);
// X轴标签
ctx.fillStyle = '#666666';
ctx.fillText(getWeekDayName(this.weekDates[i]), xBase + groupW / 2, totalH - 6);
}
// 图例
ctx.fillStyle = '#34C759';
ctx.fillRect(totalW - 80, 6, 10, 10);
ctx.fillStyle = '#666666';
ctx.fillText('饮水', totalW - 66, 15);
ctx.fillStyle = '#AF52DE';
ctx.fillRect(totalW - 40, 6, 10, 10);
ctx.fillText('睡眠', totalW - 26, 15);
}
6.4 Canvas 绑定与重绘
Canvas(this.barCtx)
.width('100%')
.height(190)
.onReady(() => {
this.drawStepChart();
})
// 数据变化时重绘
async loadWeekData(): Promise<void> {
this.records = await storageManager.loadRecordsInRange(this.weekDates);
setTimeout(() => {
this.drawStepChart();
this.drawWaterChart();
}, 100);
}
七、统计页:周导航与摘要
7.1 周导航
@State weekStartDate: string = '';
@State weekDates: string[] = [];
aboutToAppear(): void {
this.weekStartDate = getWeekStart(getTodayString());
this.weekDates = getWeekDates(this.weekStartDate);
this.loadWeekData();
}
prevWeek(): void {
const d = new Date(this.weekStartDate);
d.setDate(d.getDate() - 7);
this.weekStartDate = formatDate(d);
this.weekDates = getWeekDates(this.weekStartDate);
this.loadWeekData();
}
nextWeek(): void {
const d = new Date(this.weekStartDate);
d.setDate(d.getDate() + 7);
this.weekStartDate = formatDate(d);
this.weekDates = getWeekDates(this.weekStartDate);
this.loadWeekData();
}
7.2 周摘要统计
// 计算平均值
computeAvg(field: string): number {
let sum = 0;
for (const r of this.records) {
if (field === 'steps') sum += r.steps;
else if (field === 'water') sum += r.water;
else if (field === 'sleep') sum += r.sleep;
else if (field === 'calories') sum += r.calories;
}
return Math.round(sum / this.records.length);
}
// 找出最佳步数日
computeBestDay(field: string): string {
let bestIdx = 0, bestVal = -1;
for (let i = 0; i < this.records.length; i++) {
const val = this.records[i][field];
if (val > bestVal) {
bestVal = val;
bestIdx = i;
}
}
return '周' + getWeekDayName(this.weekDates[bestIdx]);
}
八、设置页:用户目标管理
8.1 编辑模式切换
@State isEditing: boolean = false;
// 查看模式:只读显示
if (!this.isEditing) {
Button('编辑目标')
.onClick(() => { this.isEditing = true; })
}
// 编辑模式:输入框
if (this.isEditing) {
Column() {
this.inputRow('昵称', this.userName, (val) => { this.userName = val; })
this.inputRow('步数目标', this.stepGoal, (val) => { this.stepGoal = val; })
// ...
Row() {
Button('取消').onClick(() => { this.isEditing = false; this.loadSettings(); })
Button('保存').onClick(() => { this.saveSettings(); })
}
}
}
8.2 数据清除确认
showConfirmDialog(): void {
AlertDialog.show({
title: '确认清除',
message: '将清除所有健康记录和设置,此操作不可恢复。确定继续?',
primaryButton: {
value: '取消',
action: () => {}
},
secondaryButton: {
value: '确认清除',
fontColor: '#FF3B30',
action: () => {
this.clearAllData();
}
}
});
}
async clearAllData(): Promise<void> {
await storageManager.clearAll();
await this.loadSettings();
promptAction.showToast({ message: '🗑️ 所有数据已清除' });
}
九、底部 Tab 导航
9.1 Tabs 组件
@Entry
@Component
struct Index {
@State currentIndex: number = 0;
build() {
Tabs({
barPosition: BarPosition.End, // 底部导航
index: this.currentIndex
}) {
TabContent() { HomePage() }
.tabBar(this.tabBarBuilder('🏠', '首页', 0))
TabContent() { LogPage() }
.tabBar(this.tabBarBuilder('📝', '记录', 1))
TabContent() { StatsPage() }
.tabBar(this.tabBarBuilder('📊', '统计', 2))
TabContent() { ProfilePage() }
.tabBar(this.tabBarBuilder('⚙️', '设置', 3))
}
.vertical(false)
.scrollable(true)
.onChange((index: number) => {
this.currentIndex = index;
})
}
@Builder
tabBarBuilder(icon: string, label: string, targetIndex: number) {
Column() {
Text(icon).fontSize(22)
Text(label)
.fontSize(10)
.fontColor(this.currentIndex === targetIndex
? $r('app.color.primary')
: $r('app.color.text_secondary'))
}
.width('100%')
.padding({ top: 6, bottom: 6 })
.alignItems(HorizontalAlign.Center)
}
}
十、技术要点总结
10.1 Preferences 存储模式
┌─────────────────────────────────────┐
│ StorageManager (单例) │
│ ├── init(context) 初始化 │
│ ├── saveSettings() / loadSettings() │
│ ├── saveRecord() / loadRecord() │
│ └── loadRecordsInRange() │
└─────────────────────────────────────┘
↓ 封装
┌─────────────────────────────────────┐
│ @ohos.data.preferences │
│ ├── put(key, value) │
│ ├── get(key, default) │
│ ├── flush() 写入磁盘 │
│ └── clear() 清除 │
└─────────────────────────────────────┘
10.2 Canvas 绘图流程
1. 计算布局(padding、spacing)
2. 计算数据最大值
3. 绘制网格线 + Y轴标签
4. 绘制柱体(fillRect)
5. 绘制数值标签
6. 绘制 X轴标签
7. 绘制图例(双柱图)
10.3 自定义组件设计原则
| 原则 | 说明 |
|---|---|
| 单一职责 | 每个组件只做一件事 |
| @Prop 只读 | 父→子单向传递 |
| @Link 双向 | 子→父双向绑定 |
| 可配置性 | 通过参数控制样式和行为 |
十一、运行效果

结语
这个项目让我深刻体会到:
- 数据持久化是基础:没有本地存储,APP 只是空壳
- 图表是数据可视化的灵魂:Canvas 绘图能力很重要
- 组件封装提升效率:自定义组件让代码更简洁
- 用户体验需要细节打磨:轮播、进度条、确认对话框
代码已在文中完整呈现,希望对你有所帮助!
技术栈:HarmonyOS NEXT + ArkTS + Preferences + Canvas 2D
代码行数:~1200 行 | 自定义组件:4 个 | 页面数量:4 个
更多推荐

所有评论(0)