22、数据存储:SharedPreferences、Room数据库在TV应用中的实践

TV应用的数据存储,说实话,跟手机端有挺大区别的。你想想看,用户拿着遥控器,坐在三米外看电视,操作频率低,但数据持久化的需求一点不少——用户偏好设置、观看历史、收藏列表,这些都得存下来。

我这些年做TV开发,踩过不少存储相关的坑。今天咱们就聊聊SharedPreferences和Room数据库在TV场景下的正确用法。

22.1 SharedPreferences:轻量级配置存储

SharedPreferences适合存什么?说白了就是键值对,比如用户设置的音量、字幕开关、语言偏好这些。数据量小,结构简单,用SP最合适。

核心要点:TV应用中,SP主要用于存储用户配置,而非业务数据。

22.1.1 基本用法

// 获取SP实例,建议用应用级别的Context
SharedPreferences sp = context.getSharedPreferences("tv_config", Context.MODE_PRIVATE);

// 写入数据
sp.edit()
    .putBoolean("subtitle_enabled", true)
    .putString("language", "zh-CN")
    .putInt("volume_level", 70)
    .apply(); // 注意用apply()而非commit()

// 读取数据
boolean subtitleEnabled = sp.getBoolean("subtitle_enabled", false);
String language = sp.getString("language", "en");
int volume = sp.getInt("volume_level", 50);

这里有个细节:apply()是异步写入,commit()是同步写入。TV应用里,我建议一律用apply()。为什么?因为TV的UI线程很宝贵,同步写入可能卡顿,用户按遥控器时能明显感觉到延迟。

我的习惯:在TV应用中,所有SP写入操作都使用apply()。如果确实需要知道写入结果,可以用apply()配合SharedPreferences.OnSharedPreferenceChangeListener监听。

22.1.2 TV场景下的特殊处理

TV应用有个特点:用户可能长时间不操作,然后突然连续操作。比如调音量,用户可能连按十几次。这时候如果每次按键都写SP,性能开销不小。

我建议的做法是:

// 防抖写入:500ms内只写一次
private Handler mHandler = new Handler();
private Runnable mSaveRunnable;

public void saveVolumeWithDebounce(int volume) {
    if (mSaveRunnable != null) {
        mHandler.removeCallbacks(mSaveRunnable);
    }
    mSaveRunnable = () -> {
        sp.edit().putInt("volume_level", volume).apply();
    };
    mHandler.postDelayed(mSaveRunnable, 500);
}

嗯,这个模式我在多个TV项目里都用过,效果不错。用户连按遥控器时,只有最后一次操作会被保存。

22.2 Room数据库:结构化数据持久化

当数据量变大、结构变复杂时,SP就不够用了。比如用户的观看历史、收藏的频道列表、播放进度记录——这些需要增删改查,用Room才是正道。

Room是Google官方推荐的ORM框架,基于SQLite封装。我刚开始用的时候觉得有点重,但用习惯了发现,它确实能减少很多样板代码。

22.2.1 实体定义

@Entity(tableName = "watch_history")
public class WatchHistory {
    @PrimaryKey(autoGenerate = true)
    private long id;
    
    @ColumnInfo(name = "content_id")
    private String contentId;
    
    @ColumnInfo(name = "content_title")
    private String contentTitle;
    
    @ColumnInfo(name = "progress")
    private long progress; // 播放进度,单位毫秒
    
    @ColumnInfo(name = "duration")
    private long duration; // 总时长
    
    @ColumnInfo(name = "last_watch_time")
    private long lastWatchTime; // 最后观看时间戳
    
    // getter/setter 省略
}

注意:TV应用中,contentId建议用String类型而非int。因为不同内容源(如优酷、爱奇艺)的ID格式可能不同,String兼容性更好。我曾经因为这个踩过坑,后来统一改成String了。

22.2.2 DAO接口

@Dao
public interface WatchHistoryDao {
    @Query("SELECT * FROM watch_history ORDER BY last_watch_time DESC LIMIT 50")
    LiveData<List<WatchHistory>> getRecentHistory();
    
    @Query("SELECT * FROM watch_history WHERE content_id = :contentId")
    WatchHistory getByContentId(String contentId);
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    void insert(WatchHistory history);
    
    @Query("DELETE FROM watch_history WHERE last_watch_time < :threshold")
    void deleteOldRecords(long threshold);
    
    @Query("DELETE FROM watch_history")
    void clearAll();
}

这里我用了LiveData包装查询结果。TV应用里,数据变化后UI需要自动更新,LiveData配合ViewModel用起来很顺手。

22.2.3 数据库实例

@Database(entities = {WatchHistory.class, Favorite.class}, version = 1, exportSchema = false)
public abstract class TvDatabase extends RoomDatabase {
    private static volatile TvDatabase INSTANCE;
    
    public abstract WatchHistoryDao watchHistoryDao();
    public abstract FavoriteDao favoriteDao();
    
    public static TvDatabase getInstance(Context context) {
        if (INSTANCE == null) {
            synchronized (TvDatabase.class) {
                if (INSTANCE == null) {
                    INSTANCE = Room.databaseBuilder(
                        context.getApplicationContext(),
                        TvDatabase.class,
                        "tv_app.db"
                    )
                    .fallbackToDestructiveMigration()
                    .build();
                }
            }
        }
        return INSTANCE;
    }
}

避坑指南:TV应用升级时,数据库迁移是个麻烦事。我建议开发阶段用fallbackToDestructiveMigration(),发布前再写正式的Migration。另外,数据库版本号一定要谨慎递增,我曾经手滑改错过,导致用户数据全丢了……

22.3 TV场景下的存储策略对比

特性 SharedPreferences Room数据库
适用场景 用户配置、开关状态 结构化业务数据
数据量 小(KB级别) 大(MB级别)
查询能力 仅键值对 SQL查询、排序、过滤
线程安全 需自行处理 内置线程安全
类型安全 弱(需类型转换) 强(编译时检查)
性能 读写快,但大量写入卡顿 批量操作高效

22.4 知识体系结构图

TV应用数据存储知识体系 数据存储方案 SharedPreferences 用户配置存储 防抖写入策略 apply异步写入 Room数据库 Entity实体定义 DAO数据访问 数据库迁移 选择依据:数据量 + 结构复杂度 小数据量、简单结构 → SharedPreferences | 大数据量、复杂结构 → Room

22.5 实战建议

我个人习惯是:

  • 用户设置类(音量、字幕、语言)→ SharedPreferences
  • 业务数据类(观看历史、收藏、播放进度)→ Room
  • 临时缓存类(网络请求结果、图片缓存)→ 内存缓存或磁盘缓存

另外,TV应用有个特点:用户可能长时间不关机。这意味着应用进程可能运行好几天。如果数据写入不及时,突然断电或系统重启,数据就丢了。所以,关键数据(比如播放进度)建议在onPause时立即写入,不要等。

我曾经犯过的错:有个项目里,我把播放进度放在onStop里保存。结果用户按Home键回到桌面,系统可能很快杀掉TV应用进程,onStop不一定能执行完。后来改成onPause里保存,问题就解决了。

好了,关于TV应用的数据存储,核心就是这些。记住一句话:SP管配置,Room管数据,各司其职,别混着用。


公众号:蓝海资料掘金营,微信deep3321