开发过带视频、课程这类功能的Android应用的同学应该都有同感:播放记录这个界面看着不难,但真正动手做的时候,细节非常多。这个基于Eclipse的Android项目实战做到第十七节,刚好做到播放记录界面。说句实话,这个界面在整个学习类App里属于“低频但高价值”的页面——用户可能每天学习不到十次,但每次进来都会盯着列表想:“我上次到底看到哪了?”能回答这个问题的,就是播放记录。
播放记录界面要承担的事情很明确:把用户看过的视频按时间倒序列出来,展示标题、缩略图、上次播放进度和时间,支持点击继续播放,同时还能删除单条记录或一键清空。功能不多,但涉及SQLite数据库设计、ListView与BaseAdapter的编码、EmptyView空视图处理、长按菜单和确认对话框这几个Android基础功。对于跟着教程做项目的同学来说,这一节正好把前面对SQLite和列表的知识串起来。
这篇文章我就以自己在这个项目里的实际编码过程为主线,把播放记录界面的完整实现拆开讲清楚。表结构怎么设计、DAO层怎么写、重复播放怎么去重、Adapter怎么优化、长按删除和清空记录怎么做,以及Eclipse环境下容易踩的几个坑,都会覆盖到。想要照着复现的,直接往下看,代码和步骤都是可跑的。
1. 播放记录界面的需求拆解与技术方案选型
1.1 播放记录在整个应用链路中的位置
在课程类App里,用户的行为路径通常是“首页找课 → 章节列表 → 看视频”,但第二次打开时,很少有人还记得自己看到哪个视频、看到第几分钟。这时候播放记录界面就是用户回到学习现场的入口。它不生产内容,但把用户之前的行为轨迹整理出来,让用户能一键回到上次离开的位置。从产品角度讲,这个界面做得好不好,直接影响用户对应用“懂不懂我”的感受。
从技术角度讲,播放记录界面是数据存储层与UI层的一次完整串联。前端是ListView列表,后端是SQLite数据库里的一张播放记录表。用户每打开一个视频播放页,我们需要把“哪个视频、看到哪一秒、什么时间看的”这三个核心信息写入数据库;播放记录界面再把数据库里的内容读出来,按时间倒序排好展示。后续每次点击列表项,还要把记录里的进度位置带回播放页做续播。
1.2 技术选型:SQLite加ListView为什么够用
这个项目整体是基于Eclipse加ADT环境,跑的是原生Android API。说到本地持久化,Android自带SQLite就是最直接的选择。
可能有人会问:现在有ORM框架,有Room,为什么不直接用?理由很简单:第一,Eclipse环境加老项目本身就没法顺畅引入最新Room等库,引入外部依赖会引入一堆兼容问题;第二,播放记录这种单表、轻量、数据量不大的场景,用SQLiteOpenHelper写原生SQL完全够用,代码反而更直观。第三,这个阶段的关键是掌握数据库增删改查的基本功,用原生API能让人把原理吃透。
列表展示同样选了最基础的ListView加BaseAdapter。这个项目里播放记录数量正常不会超过几十条,ListView完全扛得住,配合ViewHolder优化,滑动也不会有卡顿感。等以后数据量大到需要分组或者复杂item复用,再考虑RecyclerView也不迟。
1.3 播放记录界面的功能清单
动手写代码之前,我习惯先把功能点列清楚,免得做一半发现漏了需求:
- 展示历史播放记录,按最近播放时间倒序排列。
- 每条记录需要展示视频标题、播放总时长、上次播放到的位置、最近播放时间。
- 同一个视频多次播放时,不产生重复记录,而是更新已有记录的进度和时间,并把记录排到最前面。
- 点击记录项,跳转到播放界面,并回传上次播放位置用于续播。
- 长按记录项弹出菜单,支持删除单条记录。
- 页面提供“清空记录”入口,二次确认后删除所有记录。
- 没有记录时,显示“暂无播放记录”的提示空视图。
把这些需求翻译成代码模块,就是三部分:数据库表与DAO、Activity与Adapter、交互事件与刷新逻辑。下面逐个展开。
2. 数据层设计:播放记录表与DAO封装
2.1 表结构设计:字段直接对着界面卡片设计
播放记录表是我在一开始就敲定的,字段不需要多,但每一个都要有用。当时设计的表结构如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| _id | INTEGER PRIMARY KEY AUTOINCREMENT | 记录唯一ID |
| video_id | INTEGER | 视频ID,用于去重和跳转 |
| video_title | VARCHAR(80) | 视频标题,列表卡片主标题 |
| duration | LONG | 视频总时长,单位为毫秒 |
| position | LONG | 上次播放位置,单位为毫秒 |
| play_time | LONG | 最近一次播放的时间戳,单位为毫秒 |
| course_id | INTEGER | 所属课程ID,预留做分组或过滤功能 |
一开始我没有把course_id放进去,后来发现如果后续想做“按课程查看播放记录”,没有这个字段就得重新建表。由于onUpgrade升级逻辑还比较暴力,前期把字段设计完整,能省不少事。
duration和position为什么用毫秒而不是秒?因为播放器进度回调拿到的单位就是毫秒,存毫秒能保留播放器原始精度,展示时再自行格式化。play_time存System.currentTimeMillis()的毫秒时间戳,排序时直接ORDER BY play_time DESC即可。
2.2 SQLiteOpenHelper封装播放记录数据库
在Eclipse里新建一个DatabaseHelper类,继承SQLiteOpenHelper。核心代码如下:
public class PlayRecordDBHelper extends SQLiteOpenHelper { public static final String DB_NAME = "course_player.db"; public static final String TABLE_PLAY_RECORD = "play_record"; public static final int DB_VERSION = 1; public PlayRecordDBHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { String createSql = "CREATE TABLE IF NOT EXISTS " + TABLE_PLAY_RECORD + " (" + "_id INTEGER PRIMARY KEY AUTOINCREMENT, " + "video_id INTEGER NOT NULL, " + "video_title VARCHAR(80), " + "duration LONG, " + "position LONG, " + "play_time LONG, " + "course_id INTEGER)"; db.execSQL(createSql); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 开发调试阶段可以简单粗暴地删除重建 db.execSQL("DROP TABLE IF EXISTS " + TABLE_PLAY_RECORD); onCreate(db); } }这里提一个细节:onUpgrade直接删表重建,只适合开发和课程练习阶段。如果应用已经上线,用户设备上存着真实数据,这种写法会把记录全删掉。生产环境升级数据库应该用ALTER TABLE或者按版本迁移。这个点大家心里有数就行,项目学习阶段先保证功能跑通。
2.3 DAO封装:让Activity少写SQL
直接在Activity里拼SQL、操作Cursor会显得代码很乱,而且Activity生命周期复杂,稍不注意就出现资源泄漏。最好把数据库读写单独封装成一个PlayRecordDao,Activity只调用简单方法。
我写的DAO包含以下方法:
public class PlayRecordDao { private PlayRecordDBHelper helper; public PlayRecordDao(Context context) { helper = new PlayRecordDBHelper(context); } // 插入一条记录 public long insertRecord(PlayRecord record) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put("video_id", record.getVideoId()); values.put("video_title", record.getVideoTitle()); values.put("duration", record.getDuration()); values.put("position", record.getPosition()); values.put("play_time", record.getPlayTime()); values.put("course_id", record.getCourseId()); long rowId = db.insert(TABLE_PLAY_RECORD, null, values); db.close(); return rowId; } // 根据videoId更新记录 public int updateRecord(PlayRecord record) { SQLiteDatabase db = helper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put("video_title", record.getVideoTitle()); values.put("duration", record.getDuration()); values.put("position", record.getPosition()); values.put("play_time", record.getPlayTime()); values.put("course_id", record.getCourseId()); int rows = db.update(TABLE_PLAY_RECORD, values, "video_id=?", new String[]{String.valueOf(record.getVideoId())}); db.close(); return rows; } // 查询全部播放记录,按最近时间倒序 public List<PlayRecord> getAllRecords() { List<PlayRecord> list = new ArrayList<PlayRecord>(); SQLiteDatabase db = helper.getReadableDatabase(); Cursor cursor = db.query(TABLE_PLAY_RECORD, null, null, null, null, null, "play_time DESC"); if (cursor != null) { while (cursor.moveToNext()) { PlayRecord record = new PlayRecord(); record.setId(cursor.getInt(cursor.getColumnIndex("_id"))); record.setVideoId(cursor.getInt(cursor.getColumnIndex("video_id"))); record.setVideoTitle(cursor.getString(cursor.getColumnIndex("video_title"))); record.setDuration(cursor.getLong(cursor.getColumnIndex("duration"))); record.setPosition(cursor.getLong(cursor.getColumnIndex("position"))); record.setPlayTime(cursor.getLong(cursor.getColumnIndex("play_time"))); record.setCourseId(cursor.getInt(cursor.getColumnIndex("course_id"))); list.add(record); } cursor.close(); } db.close(); return list; } // 根据记录ID删除单条 public int deleteRecordById(int id) { SQLiteDatabase db = helper.getWritableDatabase(); int rows = db.delete(TABLE_PLAY_RECORD, "_id=?", new String[]{String.valueOf(id)}); db.close(); return rows; } // 清空所有记录 public void deleteAll() { SQLiteDatabase db = helper.getWritableDatabase(); db.delete(TABLE_PLAY_RECORD, null, null); db.close(); } }查询部分有个习惯我觉得值得坚持:用db.query而不是直接拼接SELECT字符串。这样能避免自己拼SQL时漏掉空格或写错语法,同时也算最简单的SQL注入防护。Cursor用完之后及时关闭,数据库操作结束后close,这个习惯在Eclipse时代特别重要,因为那时候没有LeakCanary帮你定位泄漏,基本都是靠日志和代码检查。
3. 去重逻辑与展示层的数据处理
3.1 同一个视频只保留一条最新记录
播放记录界面最容易出现的体验问题是:同一个视频看一次插一条,列表里塞满重复标题,看着极其廉价。去重逻辑不复杂,但要在写DAO之前想清楚。
我的做法是:当播放界面准备写入记录时,先用videoId查数据库。如果存在这条视频的记录,就走updateRecord,把上次播放位置、总时长、播放时间都更新;如果不存在,就走insertRecord。这样列表里同一个视频永远只有一条记录,而且每次播放后play_time刷新,记录会被顶到最前面。
添加一个判断方法放到DAO里:
public boolean isExist(int videoId) { SQLiteDatabase db = helper.getReadableDatabase(); Cursor cursor = db.query(TABLE_PLAY_RECORD, new String[]{"_id"}, "video_id=?", new String[]{String.valueOf(videoId)}, null, null, null); boolean exist = cursor.moveToFirst(); cursor.close(); db.close(); return exist; }然后在写入的时候这样处理:
PlayRecord record = new PlayRecord(); record.setVideoId(videoId); record.setVideoTitle(title); record.setDuration(totalDuration); record.setPosition(currentPosition); record.setPlayTime(System.currentTimeMillis()); if (dao.isExist(videoId)) { dao.updateRecord(record); } else { dao.insertRecord(record); }有同学可能会问,这样每次播放停止都要先查一次库,效率会不会有问题?播放记录表数据量小,单条查询走索引非常快,实际使用中毫秒级返回,完全不用在意。
3.2 时间展示:时间戳与播放进度的格式化处理
播放记录界面的时间展示有两条线:一是这条记录是“什么时候看”的,存的是毫秒时间戳;二是这个视频“看到哪了”,存的是播放位置毫秒值。这两者在列表卡片上展示格式完全不同。
时间戳格式化可以用SimpleDateFormat。判断一下是否今天、昨天,体验会更好,不过这个项目里没做那么细,先保证基本格式:
public static String formatPlayTime(long timeMillis) { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.CHINA); return sdf.format(new Date(timeMillis)); }播放进度和总时长按“分:秒”展示,如果视频超过一小时就补上小时位:
public static String formatDuration(long durationMillis) { long totalSeconds = durationMillis / 1000; long hour = totalSeconds / 3600; long minute = totalSeconds % 3600 / 60; long second = totalSeconds % 60; if (hour > 0) { return String.format(Locale.CHINA, "%02d:%02d:%02d", hour, minute, second); } return String.format(Locale.CHINA, "%02d:%02d", minute, second); }这里有个很经典的坑:如果用毫秒值直接除以60算分钟,得到的是错误的分钟数。播放器给的position、duration单位都是毫秒,必须先除以1000换算成秒,再做时、分、秒的取整,否则卡片上会显示一个匪夷所思的时长。
3.3 为什么必须存储播放进度position
播放记录不只是给用户看“我学过什么”的,更核心的价值是“上次看到哪”。如果只存一个videoId和标题,点击记录后从零开始播放,那记录界面的价值直接砍掉一半。
所以position字段必须存,而且每次播放进度变化到一定阈值就更新。实际项目中,播放页的onProgress回调会不断触发,不可能每100毫秒就写一次数据库。我习惯做一层节流:只在进度超过上一次记录的position一定时间差,或者Activity进入onPause时再入库。这样既保证断点续播基本准确,又不会频繁写库导致卡顿。
播放记录界面点击列表项跳转到播放页时,把position作为附加参数传过去:
Intent intent = new Intent(PlayRecordActivity.this, VideoPlayerActivity.class); intent.putExtra("video_id", record.getVideoId()); intent.putExtra("video_title", record.getVideoTitle()); intent.putExtra("position", record.getPosition()); startActivity(intent);播放页收到position后,在视频首次准备好的回调里seekTo(position),续播就完成了。这个链路看着简单,却是整个播放记录界面的灵魂。
4. 界面搭建与Adapter实现
4.1 布局架构:ListView与EmptyView同层摆放
播放记录界面的布局文件我命名为activity_play_record.xml,根布局用RelativeLayout,里面放一个ListView和一个EmptyView。注意EmptyView不能放在ListView内部,必须是同一个父布局下的兄弟节点,这样拿到ListView的同一父容器后,系统才能正确切换空视图与列表。
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <ListView android:id="@+id/lv_record" android:layout_width="match_parent" android:layout_height="match_parent" /> <TextView android:id="@+id/tv_empty_view" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:text="暂无播放记录" android:visibility="gone" /> </RelativeLayout>Activity里只需要一行代码就能让系统自动切换:
ListView lvRecord = (ListView) findViewById(R.id.lv_record); TextView tvEmptyView = (TextView) findViewById(R.id.tv_empty_view); lvRecord.setEmptyView(tvEmptyView);调用setEmptyView之后,只要Adapter的数据源为空,ListView会自动隐藏并显示空视图;数据非空则相反。这个机制比手动判空、控制TextView可见性可靠得多,也不容易漏逻辑。
4.2 自定义Adapter与ViewHolder优化
item布局item_play_record.xml我放了三块核心信息:视频标题、播放进度占位、播放时间。缩略图这个项目里没有现成的本地图片资源,所以先用默认图标替代,图片加载这块不是本节重点,不展开。
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="12dp"> <ImageView android:id="@+id/iv_record_cover" android:layout_width="80dp" android:layout_height="50dp" android:src="@mipmap/ic_video_default" /> <TextView android:id="@+id/tv_record_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_toRightOf="@id/iv_record_cover" android:layout_marginLeft="10dp" android:layout_marginRight="10dp" android:singleLine="true" android:ellipsize="end" android:textSize="15sp" /> <TextView android:id="@+id/tv_record_progress" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_below="@id/tv_record_title" android:layout_toRightOf="@id/iv_record_cover" android:layout_marginLeft="10dp" android:layout_marginTop="6dp" android:textSize="13sp" android:textColor="#666666" /> <TextView android:id="@+id/tv_record_time" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_below="@id/tv_record_progress" android:layout_toRightOf="@id/iv_record_cover" android:layout_marginLeft="10dp" android:layout_marginTop="6dp" android:textSize="12sp" android:textColor="#999999" /> </RelativeLayout>Adapter的核心代码:
public class PlayRecordAdapter extends BaseAdapter { private Context context; private List<PlayRecord> recordList; public PlayRecordAdapter(Context context, List<PlayRecord> recordList) { this.context = context; this.recordList = recordList; } public void setData(List<PlayRecord> newList) { recordList.clear(); recordList.addAll(newList); notifyDataSetChanged(); } @Override public int getCount() { return recordList.size(); } @Override public Object getItem(int position) { return recordList.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView == null) { convertView = LayoutInflater.from(context).inflate( R.layout.item_play_record, parent, false); holder = new ViewHolder(); holder.tvTitle = (TextView) convertView.findViewById(R.id.tv_record_title); holder.tvProgress = (TextView) convertView.findViewById(R.id.tv_record_progress); holder.tvTime = (TextView) convertView.findViewById(R.id.tv_record_time); convertView.setTag(holder); } else { holder = (ViewHolder) convertView.getTag(); } PlayRecord record = recordList.get(position); holder.tvTitle.setText(record.getVideoTitle()); holder.tvProgress.setText("已观看 " + CommonUtil.formatDuration(record.getPosition()) + " / " + CommonUtil.formatDuration(record.getDuration())); holder.tvTime.setText("观看于 " + CommonUtil.formatPlayTime(record.getPlayTime())); return convertView; } static class ViewHolder { TextView tvTitle; TextView tvProgress; TextView tvTime; } }从代码里可以看到,setData方法内部做了clear和addAll,而不是替换List引用。这是一个关键细节。如果Activity里把dao查询出来的新List直接赋值给Adapter持有的list字段,有些同学会把Adapter里的list换成新对象,然后notifyDataSetChanged,表面看没问题,但一旦排序或者清空后重新加载,新对象和ListView内部缓存的引用不一致,刷新就失效。保持同一引用,只改内容,是最稳妥的做法。
4.3 点击跳转与记录回填
在Activity里给ListView设置点击事件:
lvRecord.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { PlayRecord record = (PlayRecord) parent.getItemAtPosition(position); Intent intent = new Intent(PlayRecordActivity.this, VideoPlayerActivity.class); intent.putExtra("video_id", record.getVideoId()); intent.putExtra("video_title", record.getVideoTitle()); intent.putExtra("position", record.getPosition()); startActivity(intent); } });这里用parent.getItemAtPosition(position)而不是直接从recordList里取,是因为ListView在显示过程中有可能存在header或者position偏移的情况,虽然本项目没有header,但这个习惯更保险。
5. 删除与清空:长按菜单与确认交互
5.1 长按记录项弹出上下文菜单
播放记录界面要有删除功能,这是刚需。我选的是长按弹出上下文菜单的方式,这也是Android原生交互中最符合“长按列表项操作”习惯的做法。
Activity的onCreate里注册:
registerForContextMenu(lvRecord);然后重写onCreateContextMenu和onContextItemSelected:
@Override public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) { super.onCreateContextMenu(menu, v, menuInfo); AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo; PlayRecord record = (PlayRecord) lvRecord.getItemAtPosition(info.position); menu.setHeaderTitle(record.getVideoTitle()); menu.add(0, MENU_DELETE, 0, "删除该记录"); } @Override public boolean onContextItemSelected(MenuItem item) { if (item.getItemId() == MENU_DELETE) { AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) item.getMenuInfo(); PlayRecord record = (PlayRecord) lvRecord.getItemAtPosition(info.position); deleteSingleRecord(record); return true; } return super.onContextItemSelected(item); }关键点在于getMenuInfo()拿到的是AdapterContextMenuInfo,它内部包含position字段,这个position是长按条目在ListView中的位置,直接用来取Record。如果你自己维护了一个recordList,也可以直接用recordList.get(info.position),但需要保证ListView没有header且数据与ListView完全同步。
删除单条后,除了删数据库,还必须同步删除内存List里的对象,再notifyDataSetChanged,列表才会消失。直接重新查库再刷新也行,但这个场景里只删一条,操作内存列表更轻量。注意notifyDataSetChanged要在主线程执行,这些代码本来就在主线程,没问题。
5.2 一键清空与二次确认
清空全部记录放在页面标题栏的操作按钮里。点击后弹出确认对话框,避免用户误触导致不可恢复。Eclipse时代没有后来AndroidX的Material风格,直接用AlertDialog就够了,代码清爽不引入额外依赖。
private void showClearAllDialog() { AlertDialog.Builder builder = new AlertDialog.Builder(PlayRecordActivity.this); builder.setTitle("提示"); builder.setMessage("确认清空所有播放记录吗?操作不可恢复。"); builder.setPositiveButton("清空", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { clearAllRecords(); } }); builder.setNegativeButton("取消", null); builder.show(); }clearAllRecords里调用dao.deleteAll(),然后清空内存List并刷新:
private void clearAllRecords() { dao.deleteAll(); recordList.clear(); adapter.notifyDataSetChanged(); }这里不必再调用adapter.setData(new ArrayList),直接clear加notify就够了。数据库已经清空,内存List清空后,ListView和EmptyView会由setEmptyView自动切换,不存在显示空白列表的情况。
6. 基于Eclipse开发时踩过的坑与排查记录
6.1 数据库文件在模拟器上怎么看
Eclipse时代Debug最常用的就是DDMS视图。播放记录写入后,想确认数据到底存没存进去,可以在DDMS的File Explorer里找到/data/data/项目包名/databases/目录,下面会有一个course_player.db文件。把它导出到本地,用SQLite的浏览器工具打开,就能直接看到play_record表的数据。
这个习惯很值得培养。很多时候代码逻辑看起来没问题,但列表就是没数据,打开数据库一看,发现表建错字段名或者插到了别的表。直接查库比在代码里打一堆Log更快。
6.2 Cursor取不到数据的一个常见原因
有次反复查询都返回空,后来发现是建表SQL里的字段名是play_time,而我自己在Cursor读取时写成了playTime,列名大小写对不上。SQLite的列名是区分大小写的,Cursor.getColumnIndex如果找不到列会返回-1,再调getLong会抛异常,这个问题在Eclipse时代排查起来很折磨,因为有时Log被过滤掉,异常一闪而过。
建议是统一约定数据库字段名全部小写下划线,JavaBean字段用驼峰,Cursor取值时严格对照建表SQL里的列名。
6.3 列表刷新与空视图切换的经典Bug
还有一个高频问题是:清空记录后,列表变成了空白,但没有显示“暂无播放记录”。原因基本都能收敛到两个:
一是没有调用setEmptyView。没设置之前,ListView数据为空时,列表区域就是空白,不会自动显示空视图。
二是EmptyView和ListView不在同一父布局。setEmptyView内部会检查EmptyView的parent,如果EmptyView是ListView的子view,切换时处理不当,后续ListView数据变化时可能异常。把EmptyView和ListView放在同一层的RelativeLayout里,就不会有这个问题。
6.4 R文件丢失与编译环境杂项
Eclipse里写UI,最烦的是一觉醒来项目报R cannot be resolved。通常Clean一下项目再重新Build就能解决。这往往是因为资源文件命名非法、图片放入位置不对,或者自动生成的gen目录状态异常。新建资源时最好全部使用小写字母和下划线,Android资源文件命名规则比现在Android Studio更严格。
另外模拟器跑项目时,如果用低分辨率的AVD,播放记录界面的中文文字可能发虚,这是模拟器字体渲染的问题,不影响真机。遇到进度与时间显示间隔时顺手调一下逻辑,不用在模拟器显示上花太多时间。
最后再分享一个我在Eclipse时代养成的习惯:每做完一个小功能,比如插入记录、列表展示、删除单条,都单独跑一遍真机验证,而不是攒到最后统一调。播放记录界面这个功能跨数据层和UI层,问题定位成本偏高,分阶段验证能让你最快锁定问题在哪一层,调试体验会舒服很多。