ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

时间沙漏APP开发:自定义View动画与Room持久化实战

时间沙漏APP开发:自定义View动画与Room持久化实战 简介面向计算机专业正在完成期末大作业或课程设计的学生这份基于安卓开发环境的时间沙漏APP源码来自导师认可的高分项目适合需要安卓项目实战参考的学习者。资源共79个文件压缩包约18.89MB包含9个Java源码、18个XML布局与配置、27张PNG和10张WebP界面素材另有Gradle构建脚本、可直接安装的时间沙漏APK和README说明文档从界面绘制到项目构建均有覆盖目录结构清晰。目前已有177人学习下载。通过整套源码读者可以理解时间沙漏计时逻辑、页面跳转、UI资源组织等关键模块的实现方式也能直接运行APK体验效果再结合源码扩展功能或定制界面节省从零搭建的重复工作。无论是用于期末答辩演示、课程设计报告还是作为安卓开发入门后的综合练习这份项目都能提供可复用的高分方案。1. 时间沙漏 APP 不是倒计时是把「专注时间」画给你看Android 期末答辩现场老师最爱追问三个地方界面是不是自己画的、数据存到哪了、切到后台计时还准不准。时间沙漏 APP 正好一次性趟过这三关——自定义 View 画沙漏动效、Room 存专注记录、前台服务拖住计时进程。技术边界也不夸张不碰架构轮子不引重型图表库一个人两周能写完三个人分头写更有富余。对要交「安卓期末大作业」的学生来说它踩中了「有图形、有动画、有存储、有通知」的评分点对想拿源码改着用的开发者来说它把自定义 View、计时、SQLite 这三块最常见的安卓开发考点合成一个能直接演示的项目。下面按「拆模块 → 画沙漏 → 接通计时链路 → 验收自测」的顺序把每一块怎么落地、坑在哪里一次讲完。2. 先把时间沙漏 APP 拆成四个模块动画、计时、数据、提醒2.1 模块边界一个需求只占一个包课程项目最容易翻车的原因不是功能不够而是所有代码堆进 MainActivity。时间沙漏这种程度的需求拆四个包就足够ui主界面、任务列表、统计页、设置页只负责渲染和事件转发timer倒计时核心只维护剩余毫秒数不碰任何控件dataRoom 的 Entity、Dao、Database专注记录的唯一出入口notify通知渠道、前台服务负责提醒链路这样拆有两个实际好处。第一答辩被问到「某个数据从哪来的」时你直接说出调用了哪一层哪个方法而不是翻着几百行的 Activity 现找。第二改功能不互相踩把倒计时的刷新频率从 100ms 改成 500msUI 层完全无感。主界面结构我一般搭成顶部是HourglassView自定义沙漏中间是剩余时间的TextView底部三个按钮处理开始、暂停、重置再加一个进统计页的入口。任务列表用RecyclerView每项只有任务名、预设时长、删除按钮不做过重的编辑流程——期末项目把核心链路跑通比功能堆砌更划算。2.2 数据层选型为什么选 Room 而不是裸 SQLite专注记录的结构很简单任务名、开始时间、结束时间、时长。看起来用SQLiteOpenHelper手写 SQL 也够但课程项目里一个高频翻车点就是手写 SQL 字符串拼接出错运行时才炸。Room 的查询在编译期就要过 SQL 校验写错直接编译报错这一条就把期末演示的翻车率压下来一大截。方案编译期校验与 LiveData/Flow 配合学习成本期末项目建议手写 SQLiteOpenHelper无需自己包低不推荐易出运行时错Room有可直接返回 LiveData中推荐性价比最高文件/SharedPreferences无无最低只适合存设置不适合统计Room 在 Android Studio 里是三件套一个Entity数据类、一个Dao接口、一个继承RoomDatabase的抽象类。依赖按官方当前稳定版引入plugins { id com.google.devtools.ksp version 2.0.21-1.0.28 } dependencies { def room_version 2.6.1 implementation androidx.room:room-runtime:$room_version ksp androidx.room:room-compiler:$room_version }如果不想配 KSP把ksp换成annotationProcessor效果一样。runtime负责跑compiler负责构建期生成数据库实现类所以每次改完 Entity 或 Dao 都要重新 Build 再跑——这是 Room 和裸 SQLite 最大的体验差异。实际开发里很多人忘了数据库实例要写成单例否则每次getDatabase都新建连接演示时频繁进出统计页就会卡顿。配件选择上统计页别急着引第三方图表库。先拿一个RecyclerView把「按天分组、当天总时长」展示清楚功能完整评分就稳了图表属于锦上添花不是得分底线。2.3 计时核心放哪Activity、ViewModel、Service 怎么分工如果倒计时写在 Activity 里屏幕一旋转onDestroy里 Handler 被清掉计时就清零重来——期末答辩十个有八个挂在这。ViewModel 能解决旋转问题因为配置变更后它依然存活但要解决「App 退到后台被系统回收」还得前台服务。位置旋转屏幕App 在后台通知栏展示实现复杂度Activity 内丢状态可能被回收无最低不推荐ViewModel不丢可能被回收无中适合演示ViewModel 前台服务不丢不丢一直显示最高最稳职责划成两层FocusTimer这个纯 Kotlin 类只算时间TimerService负责在后台活着并保持通知存在ViewModel把剩余时间转发给沙漏和文本。只要elapsedRealtime的基准还在剩余时间就能重算不会出现「服务重启后时间清零」的经典事故。第 4 章会给出可直接抄的完整结构。提示前台服务在 Android 12 以上有启动限制但课程项目通常只需真机或模拟器演示startForegroundService配合 5 秒内调startForeground的正常路径完全够用。3. 自定义 View 画沙漏先画轮廓再让沙子按时间流失3.1 沙漏轮廓用两个三角形拼坐标写死在 onSizeChanged自定义 View 第一件事是量坐标系。画沙漏不需要在onMeasure里做复杂测量直接取width和height当基准。但wrap_content时可能拿到 0 宽高所以要用resolveSize兜一下否则屏幕上什么都画不出来。玻璃轮廓我拆成两个三角形上面一个宽口朝上、尖口朝下下面一个反过来共用水平中线neckY视觉上就是沙漏的玻璃腔体class HourglassView JvmOverloads constructor( context: Context, attrs: AttributeSet? null ) : View(context, attrs) { private val density resources.displayMetrics.density private val glassPaint Paint(Paint.ANTI_ALIAS_FLAG).apply { style Paint.Style.STROKE strokeWidth 3 * density color 0xFFECE5D8.toInt() } private val sandPaint Paint(Paint.ANTI_ALIAS_FLAG).apply { color 0xFFE3B94F.toInt() } private val topInner Path() private val bottomInner Path() private lateinit var neckY: Float private lateinit var topFullY: Float private lateinit var bottomLowY: Float override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { neckY h / 2f topFullY h * 0.18f // 沙堆满时的沙面高度 bottomLowY h * 0.82f // 下沙堆最矮时的沙面高度 val neckHalf w * 0.05f val margin w * 0.04f topInner.reset() topInner.moveTo(margin, margin) topInner.lineTo(w / 2f - neckHalf, neckY) topInner.lineTo(w / 2f neckHalf, neckY) topInner.lineTo(w - margin, margin) topInner.close() bottomInner.reset() bottomInner.moveTo(margin, h - margin) bottomInner.lineTo(w / 2f neckHalf, neckY) bottomInner.lineTo(w / 2f - neckHalf, neckY) bottomInner.lineTo(w - margin, h - margin) bottomInner.close() } override fun onDraw(canvas: Canvas) { canvas.drawPath(topInner, glassPaint) canvas.drawPath(bottomInner, glassPaint) } }两个Path各四行代码拼出一个沙漏腔体瓶颈宽度由neckHalf控制取宽度的 5% 左右视觉最像沙漏。topFullY、bottomLowY和neckY是后面画沙子要反复用的三个基准高度统一写在onSizeChanged里之后 setProgress 只需要改 progress 一个值不用重算几何。玻璃用STROKE描边沙子用FILL填充最外层的帽子装饰可加可不加不影响评分点。3.2 用 clipPath 裁剪出沙堆不做逐粒碰撞网上很多沙漏教程会引导你写粒子系统几百个粒子各有坐标、速度、状态逐帧判断是否撞壁。视觉上确实唬人但三个问题很现实粒子多了低端模拟器掉帧粒子少了答辩时能看出颗粒感把时间映射到粒子数量上也麻烦。课程实践里我强烈建议用裁剪代替碰撞检测——沙堆本质上是「沙面到瓶颈之间的一块填充区域」矩形被三角形轮廓一裁形状自然就对了。var progress: Float 0f set(value) { field value.coerceIn(0f, 1f) invalidate() } private fun drawTopSand(canvas: Canvas) { val surfaceY topFullY (neckY - topFullY) * progress canvas.save() canvas.clipPath(topInner) canvas.drawRect(0f, surfaceY, width.toFloat(), neckY, sandPaint) canvas.restore() } private fun drawBottomSand(canvas: Canvas) { val surfaceY bottomLowY (neckY - bottomLowY) * progress canvas.save() canvas.clipPath(bottomInner) canvas.drawRect(0f, surfaceY, width.toFloat(), height.toFloat(), sandPaint) canvas.restore() }这里progress表示「已经流逝的时间比例」0 是刚开始上沙堆的沙面在topFullY高位1 是时间到沙面落到neckY上方一粒不剩。下沙堆完全对称沙面从bottomLowY往上涨到瓶颈。两段代码共用一条线性公式答辩被问到「动画和时间怎么同步」时一句话就能说清画面上每个沙面的高度都是剩余时间的线性函数动画只是这个函数的渲染层。注意clipPath在硬件加速下对复杂 Path 有限制但这里的 Path 只有四个顶点属于最简单情形默认硬件加速直接可用不需要额外设置。3.3 ValueAnimator 只当节拍器时间进度才是唯一输入动画驱动有个常见反模式把动画时长设成专注时长靠动画回调拿进度。这么做一旦用户暂停、恢复或系统打断动画时间就和画面脱钩。正确做法是让沙漏 View 完全不感知计时器它只认setProgress()和setRunning()两个入口谁调用它不管。节拍器用ValueAnimator无限循环每次回调只累积帧号并标记重绘private var frameIndex 0L private var running false private val clock ValueAnimator.ofFloat(0f, 1f).apply { duration 1000L repeatCount ValueAnimator.INFINITE addUpdateListener { if (running) frameIndex invalidate() } } fun setRunning(value: Boolean) { if (running value) return running value if (value) clock.start() else clock.cancel() }沙粒流动感不需要真的模拟几百粒沙在瓶颈下方画 812 个圆让它们按不同相位从瓶颈落向下沙面循环往复视觉上就是持续沙流private fun drawStream(canvas: Canvas) { val bottomSurfaceY bottomLowY (neckY - bottomLowY) * progress val streamLen (bottomSurfaceY - neckY) if (streamLen 0f) return val streamPaint Paint(sandPaint).apply { color 0xFFD9A03C.toInt() } for (i in 0 until streamCount) { val phase (frameIndex / 8 i * 11) % (streamCount * 20) val t phase / (streamCount * 20f) val y neckY t * streamLen val jitter ((i % 3) - 1) * 1.2f * density canvas.drawCircle(width / 2f jitter, y, 1.6f * density, streamPaint) } }frameless / 8控制下落速度i * 11给每粒沙错开相位jitter制造一点横向散落感沙流不至于像一根死板的竖线。setRunning(false)把 clock 取消frameIndex冻结画面定格这就是沙漏 App 最自然的暂停表现。onDraw里按「玻璃 → 上沙堆 → 沙流 → 下沙堆」的顺序画同一帧里上下沙堆和有流动的沙粒共用同一个 progress视觉上严格守恒上沙堆少了多少下沙堆就多多少。4. 时间沙漏的计时链路倒计时、前台服务与 Room 落库4.1 倒计时用 elapsedRealtime 做基准回调晚到也不怕安卓开发里写倒计时最常见的错误是子线程里Thread.sleep(1000)再发消息锁屏后 CPU 休眠醒来时间全乱。另一个常见选择CountDownTimer能用但onTick不保证准时系统忙时可能连续跳过几个 tick拿「回调次数 × 间隔」算剩余时间误差会累积。正确基准是SystemClock.elapsedRealtime()它统计的是开机以来真实流逝的时间切后台、锁屏都照走。倒计时核心只需要维护「剩余量」和「上次 tick 的时刻」每次刷新只把自上次以来真正流逝的毫秒数从剩余量里扣掉class FocusTimer( private var remainingMs: Long, private val onTick: (Long) - Unit, private val onFinish: () - Unit ) { private var running false private var baseTime 0L private val handler Handler(Looper.getMainLooper()) private val ticker object : Runnable { override fun run() { if (!running) return val now SystemClock.elapsedRealtime() remainingMs (remainingMs - (now - baseTime)).coerceAtLeast(0L) baseTime now onTick(remainingMs) if (remainingMs 0L) { running false onFinish() } else { handler.postDelayed(this, 100L) } } } fun start() { if (running) return running true baseTime SystemClock.elapsedRealtime() handler.post(ticker) } fun pause() { if (!running) return running false handler.removeCallbacks(ticker) val now SystemClock.elapsedRealtime() remainingMs (remainingMs - (now - baseTime)).coerceAtLeast(0L) onTick(remainingMs) } }核心区别在baseTime每次 tick 都把它重置为当前时刻所以无论系统回调延迟多久扣除的都是真实流逝量。pause()里先扣掉最后一次 tick 到暂停时刻的差值再停掉 Handler继续时start()从新的baseTime起步暂停多久都不影响精度。刷新间隔 100ms秒级显示足够平滑Handler 消息压力也很小。显示层把毫秒数格式化String.format(Locale.getDefault(), %02d:%02d, ms / 60000, ms % 60000 / 1000)。4.2 切后台不丢计时前台服务 通知渠道ViewModel 里持有FocusTimer解决了旋转问题但 App 退回桌面后系统内存吃紧时可能回收整个进程。课程项目里最稳的解法是前台服务只要通知栏还挂着一条常驻通知进程优先级就高得多基本不会被杀。服务本体很短难点全在通知渠道上——Android 8.0 之后不建渠道通知根本不显示「时间到了没提醒」直接露馅class TimerService : Service() { override fun onCreate() { super.onCreate() val manager getSystemService(NotificationManager::class.java) if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { manager.createNotificationChannel( NotificationChannel( CHANNEL_ID, 专注计时, NotificationManager.IMPORTANCE_LOW ) ) } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val remaining intent?.getLongExtra(EXTRA_REMAINING, 0L) ?: 0L val notification NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_hourglass) .setContentTitle(getString(R.string.app_name)) .setContentText(剩余 formatTime(remaining)) .setOngoing(true) .setOnlyAlertOnce(true) .build() startForeground(NOTIFICATION_ID, notification) return START_NOT_STICKY } }IMPORTANCE_LOW表示没有提示音、不弹横幅只静静挂在通知栏——这是前台计时的标准做法否则每次刷新响一声用户会直接卸载。setOngoing(true)配合startForeground双保险通知不可滑动删除。后续剩余时间的更新不重发startService而是由 UI 层拿同一个NotificationManager按NOTIFICATION_ID调notify()1 秒更新一次把remainingMs格式化后写进setContentText。setOnlyAlertOnce(true)保证更新时不会反复响铃。启动入口要分清普通startService杀后不重启要保后台用startForegroundService并在服务的onStartCommand里 5 秒内调startForeground。演示路径是「开始计时 → 按 Home 看通知栏时间在走 → 切回 App 时间一致」这条链路跑通前台服务这部分的分数就到手了。4.3 专注记录落库实体、DAO 与按天统计 SQL沙漏动效再好看没有数据落盘「数据持久化」评分项就是零分。专注记录字段就四个任务名、开始时间、结束时间、时长。都存毫秒时间戳格式化交给统计页数据库里不做字符串日期——用字符串当查询条件永远对不上时间边界Entity(tableName focus_record) data class FocusRecord( PrimaryKey(autoGenerate true) val id: Int 0, val taskName: String, val startTime: Long, val endTime: Long, val durationMs: Long ) Dao interface FocusRecordDao { Insert fun insert(record: FocusRecord) Query(SELECT * FROM focus_record ORDER BY startTime DESC) fun getAll(): ListFocusRecord Query(SELECT SUM(durationMs) FROM focus_record WHERE startTime :dayStart AND startTime :dayEnd) fun totalDurationBetween(dayStart: Long, dayEnd: Long): Long? }totalDurationBetween是统计页的核心把当天 0 点和次日 0 点换算成毫秒时间戳传进来一次SUM拿到当天总专注时长。空表时SUM返回null所以返回类型声明成Long?UI 层用空安全操作符兜底显示 0——这也是 Room 写统计 SQL 时的常见遗漏。数据库实例照例单例Database(entities [FocusRecord::class], version 1) abstract class AppDatabase : RoomDatabase() { abstract fun focusRecordDao(): FocusRecordDao companion object { Volatile private var INSTANCE: AppDatabase? null fun get(context: Context): AppDatabase INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, focus.db ).build().also { INSTANCE it } } } }写入时机放在onFinish()一次专注真正完成才落库。暂停再放弃的任务不写库否则统计页一堆几秒的记录答辩老师扫一眼就知道数据逻辑没想清楚。Room 升级 schema 要走addMigrations课程项目版本固定 1 就好若之后加了字段记得version加一否则升级包直接崩溃。提醒收尾部分时间到用RingtoneManager播一段系统默认铃声震动用Vibrator配三段式VibrationEffect各几行演示效果很加分。5. 验收前自测 10 个场景把 95 分以上的细节做实5.1 十个必测场景自己写的代码测不出问题是因为总走正常路径。按下面清单在模拟器和真机各过一遍尤其是标注重点的条目基本覆盖答辩老师会动手的所有入口。环境还没装利索的先照 Android Studio 安装教程把 AVD 跑通再开始测。序号自测场景预期表现关键实现1开始后锁屏再解锁时间继续走不丢elapsedRealtime2旋转屏幕剩余时间与任务状态保留ViewModel3按 Home 退后台通知栏时间在跳前台服务4时间归零通知、铃声、震动齐发onFinish 回调5暂停 5 分钟再继续剩余量不跳变pause 折算差值6中途杀进程重进不闪退历史记录可查Room 单例7完成一次专注统计页当天总时长累加正确SUM 查询8快速连点开始按钮不会起两个计时器running 状态锁9统计页无数据显示空态不崩null 兜底10真机低电量模式动效流畅不太卡帧号节流第 8 条最容易被忽略开始按钮不加状态锁快速双击会同时启动两个FocusTimer剩余时间文本互相覆盖现场点快了必翻车。处理轻量start()里if (running) return就够了。5.2 三个答辩高频追问的应答要点「沙漏动画和时间怎么同步的」是第一问。口径是沙面高度是剩余时间的线性函数setProgress每 100ms 被调用一次动画只按 60fps 重绘时间和画面之间没有中间换算。这句话带上「线性函数」和「60fps」立刻和背代码的同学拉开差距。「为什么用前台服务」是第二问。口径是普通后台进程在内存不足时会被回收前台服务因常驻通知保持高优先级配合elapsedRealtime才能保证系统调度不稳时剩余时间依然精确。顺势把START_NOT_STICKY和START_STICKY的区别带一句老师会认为你真理解了服务生命周期。「统计页的数据怎么来的」是第三问。直接说 Room 的SUM聚合查询把 DAO 那行 SQL 念出来。注意别把数据库实例每次都新建的事说漏嘴单例的synchronized就是为了这个问题准备的。三个问题答完答辩表述分基本锁定。时间充裕的话可以给沙漏加一个「沙色随专注主题变化」的小设置SharedPreferences存颜色值sandPaint.color读取主题色。这个改动只碰两个文件收益却是演示时最容易被注意到的个性化点放到所有功能测试完之后再做不冒引入回归的风险。本文还有配套的精品资源点击获取
返回列表