Eclipse下Android播放记录界面:SQLite建表与续播实现

发布时间:2026/10/14 18:21:32

Eclipse下Android播放记录界面:SQLite建表与续播实现 从第十六期到第十七期整个项目已进入收尾阶段。前面已经打通了课程列表、视频播放、用户登录等主要链路这期的播放记录界面看起来很简单——无非就是一张ListView加一个数据库表可实际动手做才发现里面的细节比想象中多得多重复条目怎么去重、进度什么时机保存、列表点击后如何续播、时间怎么显示才友好每一样都需要专门设计。这篇文章就把我在Eclipse和ADT环境下实现播放记录界面的完整过程写出来从建表到Adapter从保存时机到调试排坑一次性说透。适合正在做视频/音频类App、需要维护一条“最近观看”记录链路的朋友也适合还在维护Eclipse老项目、被各种旧问题困扰的开发者。1. 播放记录界面要解决的几个具体问题重复、续播、脏数据1.1 它和收藏的本质区别很多新手会把播放记录和收藏混为一谈我一开始也差点走了弯路。收藏是用户主动、明确的意图表达用户点一下“收藏”这条数据就固定下来不需要考虑时效性而播放记录是系统被动写入的数据用户每打开一次视频就产生一条记录当视频播到一半退出进度还要跟着更新。这就带来一个关键差异收藏可以做成“一条视频一条记录、永不更新”播放记录则必须设计成“一条视频一条记录、但内容随时可能变化”。所以在动手写代码之前要先明确播放记录的产品语义。我的做法是同一个视频在列表中永远只出现一次但它的“最近播放时间”和“播放进度”会随着每次观看被刷新。如果不去重用户看三次同一节课列表里就会出现三条几乎一模一样的记录体验非常糟糕。1.2 播放记录的三种数据来源与脏数据形态从技术链路看播放记录的数据来源有三个用户首次点击视频进入播放页播放器正在加载此时还没有任何进度需要插入一条初始记录用户观看过程中播放器定时上报进度记录被持续更新用户退出播放页时播放器把最后的位置写入数据库供下次续播。既然有持续更新就免不了产生脏数据。最常见的脏数据形态包括进度值大于总时长视频重置或者获取状态异常时容易出现、进度为负值、总时长为0却写入了一条看似正常的记录、重复的video_id条目等。这些都需要在数据写入层做一层防御不能直接把裸数据往表里塞。这里特别提醒一点播放记录的进度字段本质上是“临时状态”它和用户观看行为强相关。如果数据库里同时出现了同一条视频的多个版本记录后续“点击列表继续观看”的逻辑就会完全混乱因为系统不知道应该读取哪一条。这就是我们把“去重”放在数据层而非UI层的原因——UI层只能做展示和交互兜底真正的规则必须由DAO来控制。2. 数据层从零搭建表结构、实体类与DAO的取舍2.1 表结构设计为什么主键不是videoId数据库表设计是一切的基础。播放记录表的核心字段包括视频ID、视频名称、视频地址、封面信息、播放进度、视频总时长、最近播放时间。建表语句如下CREATE TABLE play_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, video_id INTEGER UNIQUE NOT NULL, video_name TEXT NOT NULL, video_path TEXT, cover_res INTEGER DEFAULT 0, play_progress INTEGER DEFAULT 0, total_duration INTEGER DEFAULT 0, update_time INTEGER NOT NULL );这里有几个设计上的取舍值得展开。第一主键是自增id而不是video_id虽然业务上每个video_id只能存在一条记录但播放记录表在后续版本中可能会增加“针对同一条视频的不同清晰度保留多条记录”之类的需求使用自增主键能保留扩展空间而UNIQUE约束保证当前阶段的去重刚性。第二play_progress和total_duration都用整型存毫秒而不是浮点存秒。毫秒精度对续播定位来说足够整数类型在SQLite里排序和比较都更高效也避免了浮点误差。第三update_time我坚持用INTEGER存时间戳而不是直接存“2025-06-20 14:30:00”这样的字符串。为什么不用字符串因为字符串排序遵循字典序虽然“yyyy-MM-dd HH:mm:ss”格式的字典序恰好等于时间顺序但如果哪天需要按周、按月统计播放记录就必须在SQL里做字符串截取和格式化非常痛苦。而时间戳是长整型直接ORDER BY update_time DESC就是天然的时间倒序展示层要做“今天/昨天”之类的格式化也非常方便。2.2 PlayRecord实体类与DBHelper实体类没什么技巧就是纯字段和getter/setter。但要注意建议在实体类里额外提供一个计算进度百分比的辅助方法这样Adapter里就不用重复写百分比算法减少逻辑散布。public class PlayRecord { private int id; private int videoId; private String videoName; private String videoPath; private int coverRes; private int playProgress; private int totalDuration; private long updateTime; public int getPlayPercent() { if (totalDuration 0) { return 0; } long percent (long) playProgress * 100 / totalDuration; return (int) percent; } }注意getPlayPercent里我把playProgress先转成了long再做乘法如果直接int相乘两个大毫秒数相乘很容易溢出成负数这是视频类项目非常隐蔽的一个坑。数据层还需要一个SQLiteOpenHelper子类管理数据库的创建和版本升级。Eclipse时代有个习惯很多人喜欢把建表语句全部堆在onCreate里导致后面每次改表结构都要清数据重来。推荐的做法是每个表一个常量字符串onCreate里逐个执行。public class PlayRecordDBHelper extends SQLiteOpenHelper { public static final String DB_NAME learning_app.db; public static final int DB_VERSION 1; public static final String TABLE_PLAY_RECORD play_record; public PlayRecordDBHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE TABLE play_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, video_id INTEGER UNIQUE NOT NULL, video_name TEXT NOT NULL, video_path TEXT, cover_res INTEGER DEFAULT 0, play_progress INTEGER DEFAULT 0, total_duration INTEGER DEFAULT 0, update_time INTEGER NOT NULL)); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // 版本升级时在这里做ALTER TABLE而不是直接删表 } }Eclipse项目有一个特殊之处所有数据库相关的类都会被打进最终的APK但调试时很难像看网络请求一样直观看到数据库内容后面我会专门讲调试方法。2.3 DAO封装addOrUpdate是核心方法DAO层不需要过度设计但接口划分要清晰。我的PlayRecordDAO包含五个方法addOrUpdate插入或更新、queryAllDesc按时间倒序查询全部、queryByVideoId查询单条、deleteByVideoId删除单条、clearAll清空全部。最核心的addOrUpdate方法承担了去重和更新的全部逻辑public class PlayRecordDAO { private PlayRecordDBHelper helper; public PlayRecordDAO(Context context) { helper new PlayRecordDBHelper(context); } public synchronized void addOrUpdate(PlayRecord record) { SQLiteDatabase db helper.getWritableDatabase(); Cursor cursor db.query(PlayRecordDBHelper.TABLE_PLAY_RECORD, new String[]{id}, video_id?, new String[]{String.valueOf(record.getVideoId())}, null, null, null); ContentValues values new ContentValues(); values.put(video_name, record.getVideoName()); values.put(video_path, record.getVideoPath()); values.put(cover_res, record.getCoverRes()); values.put(play_progress, record.getPlayProgress()); values.put(total_duration, record.getTotalDuration()); values.put(update_time, record.getUpdateTime()); if (cursor.moveToFirst()) { db.update(PlayRecordDBHelper.TABLE_PLAY_RECORD, values, video_id?, new String[]{String.valueOf(record.getVideoId())}); } else { values.put(video_id, record.getVideoId()); db.insert(PlayRecordDBHelper.TABLE_PLAY_RECORD, null, values); } cursor.close(); } }这个方法用synchronized修饰是因为播放器页面存在定时保存和退出保存两个写入入口如果并发更新SQLite会抛“database is locked”异常。虽然单机App并发概率不高但加上synchronized几乎零成本却能省掉很多线上崩溃。queryAllDesc方法负责把记录按时间倒序拉出来public ListPlayRecord queryAllDesc() { SQLiteDatabase db helper.getReadableDatabase(); Cursor cursor db.query(PlayRecordDBHelper.TABLE_PLAY_RECORD, null, null, null, null, null, update_time DESC); ListPlayRecord list new ArrayListPlayRecord(); while (cursor.moveToNext()) { PlayRecord record new PlayRecord(); record.setId(cursor.getInt(cursor.getColumnIndex(id))); record.setVideoId(cursor.getInt(cursor.getColumnIndex(video_id))); record.setVideoName(cursor.getString(cursor.getColumnIndex(video_name))); record.setVideoPath(cursor.getString(cursor.getColumnIndex(video_path))); record.setCoverRes(cursor.getInt(cursor.getColumnIndex(cover_res))); record.setPlayProgress(cursor.getInt(cursor.getColumnIndex(play_progress))); record.setTotalDuration(cursor.getInt(cursor.getColumnIndex(total_duration))); record.setUpdateTime(cursor.getLong(cursor.getColumnIndex(update_time))); list.add(record); } cursor.close(); return list; }我一直没用ContentProvider因为项目规模还到不了多个进程共享数据的程度DAO直接访问数据库足够。过度设计在这个阶段没有意义写起来费劲后期维护也费劲。2.4 一个值得讨论的细节存时间戳还是存字符串这个细节我单独拿出来说因为很多教程里都直接存TEXT类型导致后来查“最近一周的播放记录”时不得不在Java里做一堆日期解析。我的建议始终是存储long型时间戳展示层再格式化。存时间戳还有一个额外好处排序天然正确哪怕是跨年、跨时区的数据也不会乱。字符串格式虽然看起来直观但一旦遇到“今天是2025年1月1日、昨天是2024年12月31日”这种场景字典序仍然正确可一旦你换了格式比如为了界面好看存成“1月1日 14:00”字典序就彻底不对了。为了一个后期大概率要踩的坑不如一开始就选long。3. 列表页面组装布局、Adapter与友好的时间展示3.1 主界面布局标题栏、ListView与空视图播放记录的界面结构很标准顶部标题栏右上角一个清空按钮中间是ListView底部或覆盖层一个空视图。在Eclipse时代还没有AndroidX项目用的是android.app.Activity加原生控件。主布局的核心代码如下RelativeLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent RelativeLayout android:idid/rl_title_bar android:layout_widthmatch_parent android:layout_height48dp android:background#3F51B5 TextView android:idid/tv_title android:layout_widthwrap_content android:layout_heightwrap_content android:layout_centerInParenttrue android:text播放记录 android:textColor#FFFFFF android:textSize18sp / TextView android:idid/tv_clear android:layout_widthwrap_content android:layout_heightwrap_content android:layout_alignParentRighttrue android:layout_centerVerticaltrue android:paddingRight16dp android:text清空 android:textColor#FFFFFF / /RelativeLayout ListView android:idid/lv_play_record android:layout_widthmatch_parent android:layout_heightmatch_parent android:layout_belowid/rl_title_bar android:divider#EEEEEE android:dividerHeight1dp / TextView android:idid/tv_empty android:layout_widthwrap_content android:layout_heightwrap_content android:layout_belowid/rl_title_bar android:layout_centerInParenttrue android:text暂无播放记录 android:textColor#999999 android:textSize16sp / /RelativeLayout这里有一个要求空视图必须和ListView在同一个父布局里且使用setEmptyView关联。很多初学者写成listView.setEmptyView(tvEmpty)之后没有效果大概率就是空视图层级不对或者setEmptyView的调用时机不对。我的做法是在onCreate里先setEmptyView再setAdapter。listView (ListView) findViewById(R.id.lv_play_record); tvEmpty (TextView) findViewById(R.id.tv_empty); listView.setEmptyView(tvEmpty);当adapter里的数据为空时ListView会自动隐藏空视图自动显示不需要手动控制visibility。3.2 item布局与Adapter逻辑播放记录的item布局我设计为左侧主要信息区右侧一个删除图标。主要信息区第一行是视频名称第二行是观看进度第三行是最近播放时间。RelativeLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_height72dp android:padding10dp ImageView android:idid/iv_record_cover android:layout_width56dp android:layout_height56dp android:layout_centerVerticaltrue android:scaleTypecenterCrop android:srcdrawable/ic_default_cover / TextView android:idid/tv_record_name android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_marginLeft10dp android:layout_toRightOfid/iv_record_cover android:layout_alignParentToptrue android:layout_marginTop6dp android:ellipsizeend android:singleLinetrue android:textColor#333333 android:textSize15sp / TextView android:idid/tv_record_progress android:layout_widthwrap_content android:layout_heightwrap_content android:layout_belowid/tv_record_name android:layout_marginLeft10dp android:layout_toRightOfid/iv_record_cover android:layout_marginTop4dp android:textColor#999999 android:textSize13sp / TextView android:idid/tv_record_time android:layout_widthwrap_content android:layout_heightwrap_content android:layout_belowid/tv_record_name android:layout_marginLeft10dp android:layout_toRightOfid/tv_record_progress android:layout_marginTop4dp android:textColor#BBBBBB android:textSize12sp / /RelativeLayoutAdapter的核心是BaseAdapter配合ViewHolder模式。Eclipse时代没有RecyclerViewListView的convertView复用机制必须手动维护。getView里最重要的三件事复用convertView、填充数据、绑定点击事件。Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView null) { convertView inflater.inflate(R.layout.item_play_record, parent, false); holder new ViewHolder(); holder.ivCover (ImageView) convertView.findViewById(R.id.iv_record_cover); holder.tvName (TextView) convertView.findViewById(R.id.tv_record_name); 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(); } final PlayRecord record getItem(position); holder.tvName.setText(record.getVideoName()); int percent record.getPlayPercent(); if (percent 99) { holder.tvProgress.setText(已看完); } else if (percent 0) { holder.tvProgress.setText(尚未开始); } else { holder.tvProgress.setText(已观看 percent %); } holder.tvTime.setText(TimeFormatUtil.format(record.getUpdateTime())); return convertView; }段内插入一个非常容易被忽略的Bug如果item里存在可点击的控件比如右侧删除按钮默认它会抢占ListView的触摸焦点导致整行的onItemClick无法响应。解决办法是在item根布局里加上android:descendantFocusabilityblocksDescendantFocus或给可点击控件设置android:focusablefalse。3.3 把时间戳变成“今天/昨天/更早”时间展示直接决定了播放记录的可用性。用户看到“2小时前”比看到“2025-06-20 14:30”更直观而相对时间需要根据当前时间动态计算。我在项目里写了一个TimeFormatUtil工具类public class TimeFormatUtil { public static String format(long timestamp) { long now System.currentTimeMillis(); long diff now - timestamp; if (diff 0) { diff 0; } // 一分钟内 if (diff 60 * 1000) { return 刚刚; } // 一小时内 if (diff 60 * 60 * 1000) { return (diff / (60 * 1000)) 分钟前; } // 今天跨零点判断 if (isSameDay(timestamp, now)) { return 今天 formatTime(timestamp, HH:mm); } // 昨天 if (isYesterday(timestamp, now)) { return 昨天 formatTime(timestamp, HH:mm); } // 更早 return formatTime(timestamp, yyyy-MM-dd HH:mm); } private static boolean isSameDay(long time1, long time2) { Calendar c1 Calendar.getInstance(); c1.setTimeInMillis(time1); Calendar c2 Calendar.getInstance(); c2.setTimeInMillis(time2); return c1.get(Calendar.YEAR) c2.get(Calendar.YEAR) c1.get(Calendar.DAY_OF_YEAR) c2.get(Calendar.DAY_OF_YEAR); } private static boolean isYesterday(long timestamp, long now) { Calendar c1 Calendar.getInstance(); c1.setTimeInMillis(timestamp); Calendar c2 Calendar.getInstance(); c2.setTimeInMillis(now); c2.add(Calendar.DAY_OF_YEAR, -1); return c1.get(Calendar.YEAR) c2.get(Calendar.YEAR) c1.get(Calendar.DAY_OF_YEAR) c2.get(Calendar.DAY_OF_YEAR); } }“刚刚”和“x分钟前”这类相对时间适合短时间内的记录“今天 HH:mm”和“昨天 HH:mm”适合跨天的记录再老的记录显示完整日期。这样既不打扰又能让用户迅速定位最近看的内容。4. 播放器与记录列表联动保存时机、点击续播、删除与清空4.1 保存进度的三个时机播放记录的数据源头在播放器页面。VideoPlayerActivity里保存进度的时机有三个onCreate时根据Intent传入的进度值做seekTo续播onPause时把当前进度写入数据库这是最可靠的一次保存onDestroy时也保存一次防止用户通过返回键退出时onPause之后又被杀死导致数据丢了一两秒。有一种常见做法是只在onPause保存但我实测发现某些国产ROM在后台强杀App时不会触发onPause后续的所有生命周期甚至可能只走到onStop就没了所以我会再加一个定时保存兜底。每5秒把当前进度写入数据库就算App被强杀最多丢5秒进度。定时保存的Handler写法在Eclipse时代非常经典private Handler saveHandler new Handler(); private Runnable saveRunnable new Runnable() { Override public void run() { savePlayRecord(); saveHandler.postDelayed(this, 5000); } }; Override protected void onResume() { super.onResume(); saveHandler.postDelayed(saveRunnable, 5000); } Override protected void onPause() { super.onPause(); saveHandler.removeCallbacks(saveRunnable); savePlayRecord(); }注意在onPause里要removeCallbacks否则Activity已经不可见了Handler还在疯狂写数据库浪费电量不说还有可能造成数据库写入竞争。savePlayRecord方法里要做一次脏数据过滤判断总时长和进度的合法性private void savePlayRecord() { if (videoView null) { return; } int progress videoView.getCurrentPosition(); int duration videoView.getDuration(); if (duration 0 || progress 0 || progress duration) { return; } PlayRecord record new PlayRecord(); record.setVideoId(videoId); record.setVideoName(videoName); record.setVideoPath(videoUrl); record.setCoverRes(currentCourse.getCoverRes()); record.setPlayProgress(progress); record.setTotalDuration(duration); record.setUpdateTime(System.currentTimeMillis()); playRecordDAO.addOrUpdate(record); }判断progress duration的意义在于视频播放完自动重新播放时getCurrentPosition可能短暂大于getDuration或者视频源切换时出现错位数据这些脏数据如果写进去列表里就会出现进度超过100%的异常记录。4.2 唯一键与去重更新数据库表里的video_id UNIQUE约束是去重的最后一道防线DAO里的addOrUpdate是业务层的第一道防线。两者配合保证同一课时的播放记录在列表里永远只有一条。去重逻辑用了一步经典的“先查后改”流程先按video_id去表里查查到就执行update查不到就执行insert。这个流程虽然多了一次查询但对于播放记录这种低频写入场景完全够用。不要用INSERT OR REPLACE因为REPLACE会先删除旧记录再插入新记录如果表里还有外键关联其他业务数据REPLACE会带来无意的数据丢失。4.3 点击条目续播列表点击后跳回播放器页面并携带进度参数这个交互是播放记录功能的核心价值。Intent传参时需要注意progress是int类型直接putExtra(video_progress, record.getPlayProgress())即可。到了播放器页面onCreate里取出进度值后不要立刻seekTo要等视频准备好后再执行videoView.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { Override public void onPrepared(MediaPlayer mp) { int progress getIntent().getIntExtra(video_progress, 0); if (progress 0) { videoView.seekTo(progress); } videoView.start(); } });如果在onPrepared之前就调seekToMediaPlayer内部状态还没就绪进度会定位失败用户会看到视频从头播放白白丢失了之前的进度。这里还有一个产品层面的小决策当检测到上次进度在95%以上时到底是直接续播还是从头开始。直接续播可能用户只看了最后几秒体验反而奇怪。我采用的做法是如果进度超过总时长的95%就自动从0开始播放否则续播。这个规则不复杂但需要跟产品确认清楚没有产品就自己拍板然后写清楚注释。4.4 单条删除与全部清空播放记录界面的删除操作分为单条删除和全部清空。单条删除我用ListView的OnItemLongClickListener实现长按后弹出AlertDialog确认避免误触。删除逻辑直接调用DAO按video_id删除然后重新拉取数据刷新Adapter。清空按钮位于标题栏右上角点击后也必须弹出确认框。因为清空是不可恢复操作一旦误触用户所有观看进度都没了。private void showClearConfirmDialog() { new AlertDialog.Builder(this) .setTitle(清空播放记录) .setMessage(确定要清空所有播放记录吗此操作不可恢复。) .setPositiveButton(清空, new DialogInterface.OnClickListener() { Override public void onClick(DialogInterface dialog, int which) { playRecordDAO.clearAll(); loadData(); } }) .setNegativeButton(取消, null) .show(); }loadData方法是整个列表页的“刷新中枢”每次数据源变动删除、清空、从播放器返回后都调用它内部先查询DAO再刷新Adapter保证列表和数据库始终同步。private void loadData() { recordList playRecordDAO.queryAllDesc(); if (adapter null) { adapter new PlayRecordAdapter(this, recordList); listView.setAdapter(adapter); } else { adapter.setData(recordList); adapter.notifyDataSetChanged(); } }5. Eclipse/ADT环境下的部署调试与三个老坑5.1 数据库文件到底存在哪File Explorer与sqlite3Eclipse开发时期调试SQLite最痛苦的一点是数据库文件不能像现在的Android Studio Device Explorer那样直接导出。我当时用两种方法排查数据。第一种是通过ADT自带File Explorer查看Window菜单打开DDMS切到File Explorer标签页一路展开data/data/包名/databases/就能看到learning_app.db文件。勾选右上角Pull File按钮可以把数据库文件拉出到电脑上然后用其他SQLite客户端打开检查。这招在带Root的模拟器上很好用真机上如果应用是debug签名也能通过adb shell run-as拿到访问权限。第二种是直接用adb shell配合sqlite3命令查询adb shell run-as com.demo.learnapp cd /data/data/com.demo.learnapp/databases sqlite3 learning_app.db select * from play_record;这个方式的好处是实时改完代码重装App后马上就能看数据是否正确写入。但要注意部分Android 4.x模拟器镜像里没有预置sqlite3需要找一个带sqlite3的镜像或者直接用Pull File方案。5.2 老坑1item里的删除按钮抢走点击事件播放记录item右侧如果放了一个可点击的ImageButton默认它会抢走整行的焦点。表现是用户点击item空白区域没反应点击图片按钮却能弹出删除对话框。排查了一天才发现是聚焦问题。解决方案有两个任选其一即可。一是在item根布局加android:descendantFocusabilityblocksDescendantFocus阻止内部控件抢占焦点二是把删除按钮改成ImageView并设置android:focusablefalse。我更推荐方案一一旦后续item里还要加其他可点击元素比如收藏按钮、下载按钮根布局的属性已经准备好不需要再逐个处理。5.3 老坑2时间戳“秒变”1970第一次把播放记录跑通时列表里时间显示全部是“1970-01-01 08:00”一看就知道是时间戳没传对。排查过程很有意思数据库里update_time字段存的是毫秒级时间戳然后格式化工具类里SimpleDateFormat按秒级处理时间戳除以1000少了三位数于是全部变成1970年。这个坑的本质是“单位不统一”。DAO层存的是System.currentTimeMillis()毫秒值格式化工具类默认了秒级参数。后来我在TimeFormatUtil里加了严格的注释并让方法签名直接写清“timestampMillis”避免下次又有人接手代码时困惑。5.4 老坑3改了数据库结构App旧数据崩溃项目中途我给play_record表加过一列cover_res如果直接在旧App上覆盖安装没有升级数据库版本SQLite不会自动增加字段旧的插值代码也不会有问题但如果你在新代码里查询这个字段并调用cursor.getString覆盖了一个不存在的列崩溃就来了。正确的做法是修改DB_VERSION为2在onUpgrade里执行ALTER TABLEOverride public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion 2) { db.execSQL(ALTER TABLE play_record ADD COLUMN cover_res INTEGER DEFAULT 0); } }Eclipse时代的另一个特点是项目clean和重新生成R.java是常规操作。很多看起来莫名其妙的资源找不到问题clean一下再build往往就好了这不是玄学而是ADT的增量编译偶尔会漏掉资源变更。Eclipse老项目里养成这个习惯能省不少时间。一点后续建议做播放记录这一章我最大的体会是越简单的功能越考验对数据链路的理解。你在播放器页面多写一行seekTo在DAO里多写一个去重判断在Adapter里多写一个空视图绑定看起来都是小事但串起来就是“用户看了半节课退出去明天打开App点一下就能接着看”的完整体验。如果你正在打算给自己的App加播放记录功能我建议先把表结构和DAO层写扎实再碰UI因为UI永远可以换口味重写而脏数据一旦写进数据库后面每次展示和统计都会跟着遭殃。
延伸阅读

更多相关文章

2026/10/14 18:21:32

HTTPS信任机制拆解:数字签名、数字证书与证书链

你有没有认真看过浏览器地址栏那把绿色的小锁?它旁边写着“连接是安全的”,可一旦点进去,又是“证书”、又是“签发者”、又是“有效期”,大多数人直接关掉,然后继续信任它。但当你自己搭过网站、配过HTTPS、被浏览器红…

2026/10/14 18:21:32

Vim进阶指南:用文本对象、宏与缓冲区构建你的编辑语言

1. 从“会用”到“会玩”:为什么多数人卡在Vim半山腰我见过太多人学Vim,第一天背了hjkl,第二天下载了一套配置脚本,第三个月还在用i进入插入模式、用方向键移动光标、:wq保存退出。他们不是不努力,而是把Vim当成一个“…

2026/10/14 19:42:04

Core File Kit fileIo:离线核验日志临时写入与失败收口【鸿蒙心迹】

本文的文件名、路径片段、状态、日志和界面均为演示构造,不代表已经在 HarmonyOS 设备上写盘、断电或恢复成功。示例只讨论应用沙箱内的文件处理,不涉及用户公共目录授权或云同步。 一、一个看似正常的“导出成功”提示 我见过的离线审核工具里&#xf…

2026/10/14 19:42:04

推客带货系统化:从选品到复盘的闭环实战指南

做推客带货的朋友聚在一起,聊到最后几乎都是同一个画面:朋友圈一天发十几条链接,群里红包撒了一地,月底打开佣金后台一看,数字还是那个数字。不是不努力,是打法太散。真正月入翻倍、而且翻得稳定的人&#…

2026/10/14 19:42:04

上班族长期看屏幕,干眼症用玻璃酸钠滴眼液注意事项

上班族长期看屏幕,干眼症用玻璃酸钠滴眼液注意事项现如今电脑办公、手机通勤已经成为上班族的日常。长时间紧盯电子屏幕,眨眼次数会不自觉减少,泪液蒸发过快,很多人被眼睛干涩、异物感、酸胀畏光等干眼问题困扰。根据《中国干眼临…

2026/10/14 19:37:03

电商税务核算:6 大税种的精细化处理

电商税务为什么比传统零售难 3 倍传统零售涉税面相对窄:增值税 企业所得税 个税 印花税,4 个税种撑起全部税务链路。电商的涉税面要宽得多。一家年 GMV 3 亿、同时在京东 POP 抖店 支付宝 亚马逊 速卖通运营的中型电商公司,至少要面对…

2026/10/14 11:31:03

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/14 17:41:07

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/14 2:32:55

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/14 0:00:33

DFS算法解决飞机降落调度问题实战

1. 项目背景与核心思路飞机降落问题是一个经典的调度优化场景,也是算法竞赛和面试中的高频题目。这个题目要求我们合理安排多架飞机的降落顺序和时间,确保所有飞机都能安全着陆而不发生冲突。在实际航空管制中,这类问题直接关系到飞行安全和机…

2026/10/14 0:00:33

macOS/iOS高精度计时:mach_absolute_time原理与实战

1. 项目概述:为什么mach_absolute_time是 macOS/iOS 时间测量的“黄金标准”在 macOS 和 iOS 开发中,我见过太多人用CFAbsoluteTimeGetCurrent()或NSDate.timeIntervalSinceReferenceDate做性能打点,结果在高精度场景下被系统时钟跳变、NTP 同…

2026/10/14 0:00:33

SCORM课件演示与测试全解析:从API通信到自动化验证

简介:这是一套遵循SCORM标准的交互式课件演示与测试资源,面向E-Learning课程开发者、LMS平台测试人员及在线教学从业者,用于验证课件在不同学习管理系统中的识别、传递与跟踪能力。压缩包共28个文件,约12.07MB,以swf动…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑