资讯动态

Android播放记录界面实战:SQLite与ListView完整实现

发布时间:2026/10/13 21:01:03 来源:尧图企业网站定制
开发过带视频、课程这类功能的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 表结构设计字段直接对着界面卡片设计播放记录表是我在一开始就敲定的字段不需要多但每一个都要有用。当时设计的表结构如下字段名类型说明_idINTEGER PRIMARY KEY AUTOINCREMENT记录唯一IDvideo_idINTEGER视频ID用于去重和跳转video_titleVARCHAR(80)视频标题列表卡片主标题durationLONG视频总时长单位为毫秒positionLONG上次播放位置单位为毫秒play_timeLONG最近一次播放的时间戳单位为毫秒course_idINTEGER所属课程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生命周期复杂稍不注意就出现资源泄漏。最好把数据库读写单独封装成一个PlayRecordDaoActivity只调用简单方法。我写的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 ListPlayRecord getAllRecords() { ListPlayRecord list new ArrayListPlayRecord(); 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:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent ListView android:idid/lv_record android:layout_widthmatch_parent android:layout_heightmatch_parent / TextView android:idid/tv_empty_view android:layout_widthmatch_parent android:layout_heightmatch_parent android:gravitycenter android:text暂无播放记录 android:visibilitygone / /RelativeLayoutActivity里只需要一行代码就能让系统自动切换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:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightwrap_content android:padding12dp ImageView android:idid/iv_record_cover android:layout_width80dp android:layout_height50dp android:srcmipmap/ic_video_default / TextView android:idid/tv_record_title android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_toRightOfid/iv_record_cover android:layout_marginLeft10dp android:layout_marginRight10dp android:singleLinetrue android:ellipsizeend android:textSize15sp / TextView android:idid/tv_record_progress android:layout_widthwrap_content android:layout_heightwrap_content android:layout_belowid/tv_record_title android:layout_toRightOfid/iv_record_cover android:layout_marginLeft10dp android:layout_marginTop6dp android:textSize13sp android:textColor#666666 / TextView android:idid/tv_record_time android:layout_widthwrap_content android:layout_heightwrap_content android:layout_belowid/tv_record_progress android:layout_toRightOfid/iv_record_cover android:layout_marginLeft10dp android:layout_marginTop6dp android:textSize12sp android:textColor#999999 / /RelativeLayoutAdapter的核心代码public class PlayRecordAdapter extends BaseAdapter { private Context context; private ListPlayRecord recordList; public PlayRecordAdapter(Context context, ListPlayRecord recordList) { this.context context; this.recordList recordList; } public void setData(ListPlayRecord 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和onContextItemSelectedOverride 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层问题定位成本偏高分阶段验证能让你最快锁定问题在哪一层调试体验会舒服很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑