简介一款用Java语言编写的专业体重记录APP设计源码工程适合Java学习者、移动应用开发者和健康管理产品设计人员参考。该APP围绕体重数据的记录、存储、查询与可视化展开涵盖目标体重设置、历史趋势查看等常见功能可帮助用户科学管理体重也能帮助开发者快速理解完整应用的分层实现。源码包内共包含257个文件涉及175个Java源文件、32个XML配置文件、13个文本文件、12个PNG图片、10个TTF字体、4个Gradle构建脚本及若干属性说明等压缩包大小约3.9MB各类型文件用途明确目录组织清晰便于按模块研读。该资料已有313人学习浏览适合作为课程设计、毕业设计或初级Android开发的练手项目。源码中提供了完整的业务逻辑、界面布局与资源文件并搭配构建脚本和版本控制忽略规则无论是梳理功能代码还是搭建工程化结构都具有实际参考价值是一份集学习与实战于一体的完整代码包。1. 一个「会换算、会画图、记得住历史」的体重AppJava源码从哪里落手很多人手机上装着体脂秤自带的App数据却锁在云端换个秤就丢历史。自己做一款本地优先的体重记录App需求很朴素每天记一个数字画成趋势图算BMI再提醒自己别忘记录。真动手后发现「专业」两个字全藏在细节里——按天归组日期怎么算、体重存什么类型不丢精度、图表刷新怎么做不闪屏。这套源码设计的主线就是用Java把Android应用的数据层、业务计算层、展示层完整串起来。适合刚学完Java语法、想独立做完一个App的开发者也适合想给自己做一款私人体重管家、不想把数据交给云服务的人。2. 数据层先行体重记录的表结构与DAO封装2.1 为什么用SQLite 手写DAO而不是直接上Room很多教程一上来就推荐Room但体重记录这个场景数据量极小一个人一天撑死记三五条一年也就一千条左右。SQLite直接写SQL代码量不比Room多却能让每个字段的存储意图暴露在眼前这对想读懂源码、复现思路的人更友好。Room的注解生成机制在项目变大、表多的时候确实省事但它的迁移逻辑、TypeConverter一旦出问题排查成本比SQLite裸SQL高得多。我的习惯是源码学习或自用项目用SQLite 手写DAO团队协作项目再考虑Room。手写DAO还有一层好处你可以精确控制查询语句比如「最近30天按天取最后一条」「按周汇总平均值」这类体重场景的高频查询写出来就是几行SQL不需要去绕Room的查询注解。数据层不复杂源码的可读性比框架的「最佳实践」更重要这也是这套设计把SQLHelper放在最前面的原因。2.2 建表SQL与DAO接口的Java实现体重表的核心字段不需要太多但要能撑起后续所有功能。我一般这样设计public class DBHelper extends SQLiteOpenHelper { private static final String DB_NAME weight_record.db; private static final int DB_VERSION 1; public static final String TABLE_RECORD record; public static final String COL_ID id; public static final String COL_WEIGHT_GRAM weight_gram; // 体重单位克 public static final String COL_MEASURE_TIME measure_time; // 测量时间UTC毫秒 public static final String COL_NOTE note; // 备注 public static final String COL_BMI bmi; // 冗余存BMI避免每次展示都算 // 建表语句体重用int存克measure_time用long存UTC毫秒 public static final String SQL_CREATE_RECORD CREATE TABLE TABLE_RECORD ( COL_ID INTEGER PRIMARY KEY AUTOINCREMENT, COL_WEIGHT_GRAM INTEGER NOT NULL, COL_MEASURE_TIME INTEGER NOT NULL, COL_NOTE TEXT, COL_BMI REAL); // 按时间倒序取一条用于首页今日体重卡片 public static final String SQL_QUERY_LATEST_BEFORE SELECT * FROM TABLE_RECORD WHERE COL_MEASURE_TIME ? ORDER BY COL_MEASURE_TIME DESC LIMIT 1; public DBHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } Override public void onCreate(SQLiteDatabase db) { db.execSQL(SQL_CREATE_RECORD); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 第5章会专门讲迁移这里先留空 } }代码里有三个容易忽略的点。第一个是weight_gram用int存克而不是用REAL存公斤后面计算BMI时再除以1000转成double这样不会出现0.1 0.2这种浮点尾差。第二个是measure_time用long存UTC毫秒排序、日期格式化都在展示层做数据库里不存「2024-01-01」这类字符串否则跨时区、跨格式全乱。第三个是bmi字段做冗余写入时算好存起来列表滑动时不用每条现场算。DAO层用接口定义行为实现类里写SQL。插入、按时间范围查询、删除这三个方法能撑起App八成的页面public interface WeightDao { long insert(WeightRecord record); ListWeightRecord queryByTimeRange(long startUtc, long endUtc); int deleteById(long id); } public class WeightDaoImpl implements WeightDao { private final SQLiteDatabase db; public WeightDaoImpl(Context context) { db new DBHelper(context).getWritableDatabase(); } Override public long insert(WeightRecord record) { ContentValues cv new ContentValues(); cv.put(DBHelper.COL_WEIGHT_GRAM, record.getWeightGram()); cv.put(DBHelper.COL_MEASURE_TIME, record.getMeasureTime()); cv.put(DBHelper.COL_NOTE, record.getNote()); cv.put(DBHelper.COL_BMI, record.getBmi()); return db.insert(DBHelper.TABLE_RECORD, null, cv); } Override public ListWeightRecord queryByTimeRange(long startUtc, long endUtc) { ListWeightRecord list new ArrayList(); Cursor c db.query(DBHelper.TABLE_RECORD, null, DBHelper.COL_MEASURE_TIME ? AND DBHelper.COL_MEASURE_TIME ?, new String[]{String.valueOf(startUtc), String.valueOf(endUtc)}, null, null, DBHelper.COL_MEASURE_TIME ASC); while (c.moveToNext()) { WeightRecord r new WeightRecord(); r.setId(c.getLong(c.getColumnIndexOrThrow(DBHelper.COL_ID))); r.setWeightGram(c.getInt(c.getColumnIndexOrThrow(DBHelper.COL_WEIGHT_GRAM))); r.setMeasureTime(c.getLong(c.getColumnIndexOrThrow(DBHelper.COL_MEASURE_TIME))); r.setNote(c.getString(c.getColumnIndexOrThrow(DBHelper.COL_NOTE))); r.setBmi(c.getDouble(c.getColumnIndexOrThrow(DBHelper.COL_BMI))); list.add(r); } c.close(); return list; } Override public int deleteById(long id) { return db.delete(DBHelper.TABLE_RECORD, DBHelper.COL_ID ?, new String[]{String.valueOf(id)}); } }这里的查询条件用和包住起止时间配合ORDER BY ... ASC返回结果天然按时间正序图表数据可以直接喂给坐标系不用再排一次序。Cursor用完记得close连接不释放是SQLite内存涨上去的常见原因。另外getWritableDatabase()建议在DAO构造时拿一次并复用不要每次方法调用都重新打开重复打开关闭成本很高。2.3 批量导入与事务写法如果App支持导入历史体重数据比如从CSV或旧数据文件恢复逐条insert会慢得肉眼可见。SQLite每次insert都是一次事务提交几百条数据就可能卡顿。批量导入的正确姿势是把所有插入包进一个显式事务public void importRecords(ListWeightRecord records) { SQLiteDatabase db new DBHelper(context).getWritableDatabase(); db.beginTransaction(); try { for (WeightRecord record : records) { ContentValues cv new ContentValues(); cv.put(DBHelper.COL_WEIGHT_GRAM, record.getWeightGram()); cv.put(DBHelper.COL_MEASURE_TIME, record.getMeasureTime()); db.insert(DBHelper.TABLE_RECORD, null, cv); } db.setTransactionSuccessful(); } finally { db.endTransaction(); } }setTransactionSuccessful()必须在endTransaction()之前调用否则事务会被回滚。导入场景还有个细节同一时间戳的记录要不要覆盖。通常做法是先查一次现有时间戳集合重复的跳过或更新避免导入两次数据翻倍。这一步在事务外做一次批量查询就行成本比在事务里逐条判断低得多。提示体重主记录表之外建议再建一张dimension表存腰围、体脂率等附带维度字段结构在onCreate里一并建好第3章的联动图表直接用。3. 专业感来自图表趋势曲线与多维度数据的展示3.1 图表方案选型开源图表库与自绘View的取舍体重趋势图是这个App的「门面」也是最容易做得不像专业产品的地方。常见的开源图表库能快速出图折线、平滑曲线、缩放、手势都现成接一个库改改数据源就能跑。坏处是库的体积和自定义成本想改坐标轴标签颜色、想加一条「目标体重」参考线都得去翻库的配置项。自绘View用Canvas画折线代码量集中在坐标变换、手势缩放和数据归一化上。体重趋势这种单序列折线图其实不算复杂一屏代码就能画出来。我的建议是分场景时间紧、先出Demo用开源库想把这个源码当作品打磨、或者想练自定义View走自绘。二者都不影响DAO层设计因为图表层只依赖「按时间升序的List」数据源是解耦的。3.2 用X轴日期、Y轴体重的折线图初始化与刷新的核心代码以常见的开源图表库为例初始化只需要几行真正决定观感的是数据集的构造方式。核心代码如下// 折线图核心设置只保留一条平滑曲线开启数值提示 lineChart.setTouchEnabled(true); lineChart.setScaleEnabled(true); lineChart.getDescription().setEnabled(false); XAxis xAxis lineChart.getXAxis(); xAxis.setPosition(XAxis.XAxisPosition.BOTTOM); xAxis.setValueFormatter((value, axis) - dateFormat.format(new Date((long) value))); YAxis yAxis lineChart.getAxisLeft(); yAxis.setAxisMinimum(30f); // 体重下限单位kg yAxis.setAxisMaximum(120f); // 体重上限单位kg lineChart.getAxisRight().setEnabled(false); // 数据刷新每次setData前先clear避免旧数据残影 ListWeightRecord records dao.queryByTimeRange(startUtc, endUtc); ListEntry entries new ArrayList(); for (WeightRecord r : records) { entries.add(new Entry(r.getMeasureTime(), (float) (r.getWeightGram() / 1000.0))); } LineDataSet dataSet new LineDataSet(entries, 体重); dataSet.setMode(LineDataSet.Mode.CUBIC_BEZIER); // 平滑曲线更贴近趋势感 dataSet.setDrawCircles(true); LineData lineData new LineData(dataSet); lineChart.setData(lineData); lineChart.invalidate();这里有个关键设计Entry的x轴直接用测量的UTC毫秒而不是「第几条数据」或格式化后的日期字符串。这么做的理由是体重记录不是等间隔的——今天早上、明天晚上、后天中午时间戳的位置才真实反映时间距离。X轴的ValueFormatter在展示层再把毫秒转成「MM-dd」数据层不用预格式化换语言换地区都不用动数据。axisMinimum和axisMaximum要按用户实际体重区间动态设置。写死30和120只能用在Demo里真实产品里应该根据查询到的记录算最小值向下取整减5最大值向上取整加5。一个200斤的用户每次看到曲线都被压扁在顶部观感会很差这是图表类App最常见的「假数据感」来源。刷新时的clear再setData是防止残影的标准做法。如果用户边称边记频繁刷新时还可以加个判断数据长度没变化就不重建LineDataSet只调用invalidate()重绘这个小优化能明显降低快速连按时的手感卡顿。3.3 腰围、体脂与体重的多折线联动用多数据集还是归一化维度表里如果存了腰围cm和体脂率%单位跟体重完全不同硬拼在一个Y轴上会让曲线贴底。两种常见方案一种给每个维度建独立Y轴左轴管体重右轴管体脂代码上是加第二个LineDataSet并指定setAxisDependency另一种把所有维度归一化成「相对首日的百分比变化」。我的建议是体重配左轴、体脂配右轴腰围不放在主图。归一化曲线虽然能同时展示变化率但也失去了绝对数值的直观性——用户看体重趋势图是想知道「今天多少斤」不是「相比上个月掉了百分之几」。右轴方案最简单也最专业体脂数据挂在右轴两个数据集互不干扰坐标轴标签只标单位不标数值视觉干净LineDataSet weightSet new LineDataSet(weightEntries, 体重); weightSet.setAxisDependency(YAxis.AxisDependency.LEFT); LineDataSet fatSet new LineDataSet(fatEntries, 体脂率); fatSet.setAxisDependency(YAxis.AxisDependency.RIGHT); fatSet.setColor(0xFFE67E22); fatSet.setCircleColor(0xFFE67E22);多数据集图表最常踩的坑是图例Legend不显示或颜色串了一般发生在数据集颜色没有显式指定时。建LineDataSet后给setColor和setCircleColor都赋同一个值图例和曲线颜色就不会打架。另一个坑是空数据显示一堆0轴查询结果为空时别直接setData而是显示「暂无数据」占位视图这个细节留到第5章细说。4. 目标与计算BMI、标准体重与进度条的Java实现4.1 BMI的边界值与时区归属BMI计算本身非常简单体重公斤 ÷ (身高米 × 身高米)但「专业」体现在两个容易翻车的地方。第一个是BMI的保存精度DB里存的是REAL展示时要保留一位小数Java里用Math.round(bmi * 10.0) / 10.0而不是直接截断否则69.96会被截成69而不是70.0。第二个是BMI结果受体重精度影响体重用int克存储后转公斤时weightGram / 1000.0是double运算这一层不会引入误差。时区问题是新手最容易忽略的用户早上八点称重存的是UTC毫秒展示时判断「今天」必须结合本地时区。正确做法是public static boolean isSameDayUtc(long currentUtc, long targetUtc, TimeZone tz) { Calendar c1 Calendar.getInstance(tz); c1.setTimeInMillis(currentUtc); Calendar c2 Calendar.getInstance(tz); c2.setTimeInMillis(targetUtc); return c1.get(Calendar.YEAR) c2.get(Calendar.YEAR) c1.get(Calendar.DAY_OF_YEAR) c2.get(Calendar.DAY_OF_YEAR); }用Calendar.getInstance(tz)而不是直接new Date().getDay()是因为Date.getDay()返回的是星期日0的老式API且不带时区参数跨时区环境一测一个错。体重记录按「自然日」归组统计时所有比较都走这个函数数据层只认UTC毫秒。这一条做到位首页「今日体重」卡片和趋势图的今日标记都不会错位。4.2 目标体重进度算法与状态判断目标功能通常是设置起始体重、目标体重App显示「已减多少、还差多少、进度百分之几」。这里最简单也最不容易出错的算法是纯差值计算全程用int克做整数运算不碰floatpublic class WeightGoal { public static final int STATUS_KEEP 0; // 保持区间 public static final int STATUS_LOSS 1; // 减重中 public static final int STATUS_GAIN 2; // 增重中 private final int startWeightGram; // 起始体重 private final int targetWeightGram; // 目标体重 private final int currentWeightGram; public int getProgressPercent() { if (startWeightGram targetWeightGram) { return targetWeightGram currentWeightGram ? 100 : 0; } if (startWeightGram targetWeightGram) { // 增重目标当前大于等于目标即完成 if (currentWeightGram targetWeightGram) { return 100; } return (int) ((currentWeightGram - startWeightGram) * 100L / (targetWeightGram - startWeightGram)); } // 减重目标当前小于等于目标即完成 if (currentWeightGram targetWeightGram) { return 100; } return (int) ((startWeightGram - currentWeightGram) * 100L / (startWeightGram - targetWeightGram)); } }注意这里全程用int克做整数运算没有float参与。两个关键参数一是减重场景下如果用户反弹超过起始体重(startWeightGram - currentWeightGram)会是负数进度按0显示而不是出现负数百分比二是乘法先于除法* 100L用long承接避免int乘法在极端体重数值下溢出。减重和增重分两支写虽然代码长了点但比一个公式套所有场景清晰得多也方便后续加「保持期」判断。进度条的颜色状态也是用户体验的一部分。减重场景下进度超过100%说明已经越过目标线UI应该给绿色提示而不是让进度条继续涨进度在70%以上但没到100%给橙色「接近目标」。这种状态判断放到WeightGoal里用一个返回枚举的方法实现UI层只做颜色映射不要在布局文件里写业务逻辑。4.3 与通知提醒的联动定时提醒的Java实现「专业」的另一个体现是每天定时提醒称重。Android上实现定时任务最稳的方案是AlarmManagerBroadcastReceiverpublic class ReminderScheduler { public static void scheduleDaily(Context context, int hour, int minute) { AlarmManager am (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); Intent intent new Intent(context, WeightReminderReceiver.class); PendingIntent pi PendingIntent.getBroadcast( context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); Calendar calendar Calendar.getInstance(); calendar.setTimeInMillis(System.currentTimeMillis()); calendar.set(Calendar.HOUR_OF_DAY, hour); calendar.set(Calendar.MINUTE, minute); calendar.set(Calendar.SECOND, 0); calendar.set(Calendar.MILLISECOND, 0); if (calendar.getTimeInMillis() System.currentTimeMillis()) { calendar.add(Calendar.DAY_OF_YEAR, 1); // 今天时间已过明天提醒 } am.setRepeating(AlarmManager.RTC_WAKEUP, calendar.getTimeInMillis(), AlarmManager.INTERVAL_DAY, pi); } }两个参数要说明白。RTC_WAKEUP表示到点会唤醒设备触发广播适合「每天称体重」这种必须提醒的场景如果是可错过、晚点补的提醒比如喝水用RTC就行省电。setRepeating在精确性上不如setExact系列且部分新系统版本对精确闹钟有权限限制对体重记录这种精度要求不高的提醒setRepeating是功耗与可靠性平衡的选择。接收器里弹通知要走通知渠道Android 8.0每条提醒通知带上「点击打开App并跳到记录页」的Intent否则通知就是纯摆设。5. 避坑与排查体重记录App最常见的5个翻车点5.1 现象体重显示越来越多的小数尾巴录入70.5kg列表里却显示70.4999999图表里的数值提示也带一长串小数。原因是体重用了float或double直接存储。录入时70.5在二进制浮点里本身就无法精确表示多次读写后UI把这个尾差暴露出来。这是场外最常见的翻车点。解决方法是数据库字段用int存克录入时把文本解析成「去掉小数点的整数克」体重公式统一为克 / 1000.0。UI格式化用String.format(Locale.CHINA, %.1f, value)别用默认Locale否则某些地区小数分隔符会变。用户看到数字多了尾巴第一反应是App算法不行这条最容易影响信任感。5.2 现象首页列表和图表数据对不上凌晨过后打开App首页「今日体重」卡片显示昨天的记录图表上今天的位置却是空的。原因是判断「今天」时用了new Date().toLocaleString()或者直接用System.currentTimeMillis()跟存储时间比大小忽略了本地时区与UTC的偏移。解决方法是统一用第4章的isSameDayUtc做按天判断查询时把「今日零点」用Calendar换算成UTC毫秒作为查询起点Calendar startOfToday Calendar.getInstance(); startOfToday.set(Calendar.HOUR_OF_DAY, 0); startOfToday.set(Calendar.MINUTE, 0); startOfToday.set(Calendar.SECOND, 0); startOfToday.set(Calendar.MILLISECOND, 0); long todayStartUtc startOfToday.getTimeInMillis();这条坑的本质是「存储用什么、展示用什么」没分清。数据层永远只存UTC毫秒日期字符串只在格式化输出的那一刻产生。5.3 现象记录超过500条之后首页滑动卡顿数据量大了以后RecyclerView滑动掉帧进入图表页面也要转圈。原因是所有查询都在主线程执行且每次列表滑动、每次进页面都触发全量查询和全量图表数据重建。解决方法是查询加LIMIT限制列表只加载最近30天图表数据放到子线程查询后post回主线程再setData。另外不要把WeightDaoImpl的db做成全局单例后又在主线程拿写锁SQLite的写操作锁库查询再快也会被写阻塞。卡顿类问题大多不是SQL本身慢而是调用时机和调用量控制不足。5.4 现象升级版本后老用户打开App闪退本地数据库还在但App升级安装后一打开就崩报错指向no such column。原因是新版本代码里加了字段比如body_fat但onUpgrade里没写迁移或者压根是空的旧库还在按旧结构跑。解决方法是版本号递增onUpgrade里写增量迁移用ALTER TABLE ADD COLUMN千万别用DROP TABLE重建那等于用户历史全丢Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion 2) { db.execSQL(ALTER TABLE TABLE_RECORD ADD COLUMN body_fat REAL DEFAULT 0); } if (oldVersion 3) { db.execSQL(ALTER TABLE TABLE_RECORD ADD COLUMN waist_cm REAL DEFAULT 0); } }每段迁移用if (oldVersion n)包住保证从1升3、从2升3都能走对路径。数据全丢的教训我见过不止一次用户卸载重装后发现一年记录没了基本直接弃用。5.5 现象桌面Widget点击没反应或打开后跳错页面Widget上显示「点击记录」点了却只是打开App首页有的机器点击完全没反应。原因是PendingIntent用了隐式Intent没有携带任何区分参数Activity侧也没有对应解析逻辑部分新系统版本对隐式Activity的Intent有启动限制。解决方法是Widget的点击Intent显式指定包名和Activity并带上时间戳参数Intent clickIntent new Intent(context, MainActivity.class); clickIntent.setAction(Intent.ACTION_VIEW); clickIntent.putExtra(widget_target, System.currentTimeMillis()); PendingIntent pi PendingIntent.getActivity( context, 1, clickIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); remoteViews.setOnClickPendingIntent(R.id.widget_card, pi);MainActivity的onCreate里用intent.getLongExtra(widget_target, 0L)判断是否从Widget进来非0就跳到对应日期。这条坑的本质是Widget的点击通道和App内部导航是两套体系必须显式约定一个唯一入口别指望系统帮你「聪明地跳转」。6. 越用越顺手的进阶技巧CSV导出与Widget刷新6.1 CSV导出字段顺序与编码坑给App加一个「导出CSV」功能用户能把体重历史备份成表格文件。CSV格式本身不难但两个坑十分常见一是中文在部分老式表格软件里打开乱码因为Windows默认按GBK解码CSV二是记录里如果备注含换行、逗号导出不做转义会破坏列结构。public static String buildCsv(ListWeightRecord records) { StringBuilder sb new StringBuilder(); sb.append(日期,体重(kg),BMI,备注\n); SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd HH:mm, Locale.CHINA); for (WeightRecord r : records) { sb.append(sdf.format(new Date(r.getMeasureTime()))).append(,); sb.append(String.format(Locale.CHINA, %.1f, r.getWeightGram() / 1000.0)).append(,); sb.append(String.format(Locale.CHINA, %.1f, r.getBmi())).append(,); String note r.getNote() null ? : r.getNote(); if (note.contains(,) || note.contains(\) || note.contains(\n)) { note \ note.replace(\, \\) \; } sb.append(note).append(\n); } // 写文件时带上UTF-8 BOM兼容Windows下的表格软件 return new String(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}) sb.toString(); }这里的关键是在文件头部拼了UTF-8 BOM老版表格软件看到BOM会按UTF-8解析中文不乱码。备注字段做了转义含逗号、引号、换行时用双引号包起来内部引号翻倍。这两条不做导出的文件要么乱码要么列错位用户导入其他系统时全是脏数据。6.2 Widget刷新称重完卡片不更新的问题Widget显示「上次体重 XXkg」但用户称完重Widget半天不更新。原因是Widget的onUpdate只在系统刷新周期触发App内数据变了它并不知道。正确做法是在称重保存成功后主动发一次Widget更新广播// 体重保存成功后调用 Intent updateIntent new Intent(context, WeightWidgetProvider.class); updateIntent.setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE); int[] ids AppWidgetManager.getInstance(context).getAppWidgetIds( new ComponentName(context, WeightWidgetProvider.class)); updateIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids); context.sendBroadcast(updateIntent);getAppWidgetIds拿的是桌面上已添加的Widget实例ID配合EXTRA_APPWIDGET_IDS一起发Provider的onUpdate会被立即触发卡片上的「上次体重」马上刷新。注意广播要在保存事务提交成功之后再发否则Widget显示新数字、App里查却是旧数据这种「图片与内容不一致」的感知比不更新还糟。做完这套源码我最大的习惯变化是所有时间字段无论什么场景一律存UTC毫秒字符串日期只存在于展示层所有体重数值一律以克为单位的int参与存储与计算。这两条准则写进任何协作文档都不起眼但每个翻车现场基本都能追溯到它俩头上。专业App和Demo的差距很多时候不在功能多少而在这些底层单位、底层时区的约定够不够死。希望帮到你。本文还有配套的精品资源点击获取