ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity本地数据库实战:SimpleSQL管理好友与聊天记录

Unity本地数据库实战:SimpleSQL管理好友与聊天记录

好友列表和聊天记录这两块数据,放在单机游戏或者弱联网应用里,最省事的做法就是直接落到本地数据库。我最早做这类需求的时候图快,用 JSON 文件存,结果好友数量一过两百、聊天记录攒到几千条,每次读写都要全量序列化反序列化,进聊天界面明显卡顿,后来换成 SQLite 才把这个问题按住。这篇就围绕 Unity 里用 SimpleSQL 这个轻量封装来管 SQLite,把好友列表和聊天记录两张核心表的建表、增删改查、性能取舍、踩坑排查完整讲一遍。适合正在做单机社交模块、或者想给游戏加个本地消息系统的朋友,哪怕你之前没碰过数据库,跟着思路走也能落地。

1. 整体方案设计与技术选型拆解

1.1 为什么好友和聊天记录适合用本地数据库

先说清楚一个前提:好友列表和聊天记录这两类数据,本质上都是结构化、可增长、需要按条件查询的数据。好友有 ID、昵称、头像索引、在线状态、分组、备注、添加时间;聊天记录有会话对象、发送方、内容、时间戳、已读标记、消息类型。这种带多个字段、还要按时间排序、按会话过滤的数据,用文件存就是自找麻烦。

我拿实际场景对比过三种常见存法。第一种是PlayerPrefs,它只适合存几个标量配置,存列表得自己拼字符串,好友一多键值爆炸,而且它底层在不同平台上的落盘时机不一致,聊天记录这种高频写入的场景根本扛不住。第二种是 JSON 序列化到文件,优点是直观,缺点是每次改动都要全量读写,聊天记录是持续追加的,全量重写文件的开销随数据量线性增长,几千条以后每次发消息都有一帧的抖动。第三种就是 SQLite,它是嵌入式关系型数据库,支持索引、支持增量写入、支持条件查询,聊天记录追加一条就是一条 INSERT,好友按分组查就是一条带 WHERE 的 SELECT,开销可控。

所以选型的核心逻辑是:数据是否结构化、是否持续增长、是否需要条件检索。三个都占,就上数据库;只占一个,文件或 PlayerPrefs 也能凑合。好友加聊天记录是三个全占的典型,SQLite 是正解。

1.2 为什么用 SimpleSQL 而不是原生 SQLite 插件

Unity 里操作 SQLite 有几条路。一是自己引Mono.Data.Sqlite那套,写起来啰嗦,连接、命令、DataReader 一套流程下来样板代码很多,而且不同平台的原生库要自己处理。二是用一些商业或开源的 ORM 封装。三是 SimpleSQL 这类轻量封装,它把连接管理、命令执行、结果映射做了简化,API 更贴近 Unity 的使用习惯。

我选 SimpleSQL 的理由很实在:它的抽象层级刚好,既不像裸写 ADO.NET 那样每张表都要手写一堆样板,也不像重 ORM 那样引入复杂的实体映射和迁移机制,对中小型项目来说学习成本和维护成本都低。它的核心对象是一个数据库管理器,负责打开连接、执行 SQL、返回结果集,你依然要自己写 SQL,但省掉了连接生命周期和参数绑定的琐碎工作。这种"半自动"的定位,对需要精确控制 SQL、又不想被框架绑架的开发者来说很舒服。

需要说明的是,SimpleSQL 的具体 API 名称和版本在不同时期可能有差异,下面给的代码是基于它常见用法的合理演绎,核心思路(连接管理、参数化查询、结果映射)是通用的,你换成别的封装甚至原生 API,逻辑是一样的。

1.3 两张核心表的结构设计思路

表结构设计是这类需求的地基,设计歪了后面全是补丁。我的原则是:字段够用就好,索引按查询来建,时间统一用整型时间戳。

好友表我设计成这样:主键用好友的唯一 ID(字符串,因为很多平台的好友 ID 是字符串形式),昵称、备注、头像索引、分组、在线状态、添加时间。这里有个细节,昵称和备注要分开存,昵称是对方设定的,备注是我方改的,展示时优先备注、没有备注回退昵称,这个逻辑在查询层做还是展示层做都行,但字段必须分开,否则改备注会污染原始昵称。

聊天记录表:自增主键、会话 ID(标识和谁聊)、发送方标识、消息内容、消息类型(文本/图片/系统提示)、时间戳、已读标记。会话 ID 这个字段是关键,它把"和 A 的聊天"和"和 B 的聊天"区分开,查询某个会话的历史就是WHERE session_id = ? ORDER BY timestamp。时间戳用整型(Unix 秒或毫秒),别用字符串存时间,字符串排序在跨时区、跨格式时容易出问题,整型排序又快又稳。

索引方面,好友表在分组字段上建索引(按分组展示是高频操作),聊天记录表在(session_id, timestamp)上建联合索引,这个联合索引的顺序很重要,会话在前时间在后,正好匹配"先按会话过滤再按时间排序"的查询模式。

2. 核心细节解析与实操要点

2.1 数据库文件的存放位置与生命周期

数据库文件放哪,直接决定了数据会不会丢、会不会被平台清理。Unity 里Application.persistentDataPath是唯一正确的选择,这个路径在各平台都指向可读写的持久化目录,卸载应用才会清空。千万别放Application.dataPath或streamingAssetsPath,前者在部分平台只读,后者是打包资源目录,运行时写入会失败或者被覆盖。

初始化时机我一般放在游戏启动的最早期,比如一个专门的初始化场景或者启动流程的第一步,确保任何界面读取数据前数据库已经就绪。这里有个坑:如果数据库文件不存在,第一次打开时要执行建表语句;如果已存在,就跳过建表。判断方式不是看文件在不在(文件可能损坏或为空),而是执行CREATE TABLE IF NOT EXISTS,让数据库自己判断,这样最稳。

注意:移动平台上应用可能被系统随时杀掉,数据库连接要在OnApplicationPause或OnApplicationQuit里妥善关闭,避免写入缓冲没落盘导致数据丢失。SQLite 默认有事务日志,正常关闭能保证一致性,异常退出可能丢最后几条未提交的写入。

2.2 参数化查询:防注入与性能的双重意义

写 SQL 最容易犯的错是字符串拼接。比如"SELECT * FROM friend WHERE id = '" + id + "'",这种写法有两个问题:一是如果 id 里带单引号,SQL 直接语法错误甚至被注入;二是每次拼接出的 SQL 文本都不同,数据库无法复用执行计划,高频查询时性能差。

参数化查询用占位符?或@param,把值单独绑定。SimpleSQL 这类封装通常提供带参数的执行方法,你传 SQL 模板和参数数组,它负责绑定。好处是值永远被当作数据而非 SQL 片段,注入无从谈起,而且相同模板的 SQL 可以复用预编译语句,批量插入时性能提升明显。我实测过批量插入一千条聊天记录,参数化加事务比字符串拼接快好几倍,这个差距在低端机上尤其明显。

2.3 事务:批量写入的性能开关

聊天记录是高频追加的场景,如果每条消息都单独提交一次事务,SQLite 每次提交都要做一次磁盘同步(fsync),这个开销在移动设备上很可观。正确做法是把一批写入包在一个事务里,比如进入聊天界面时把积压的未读消息批量落库,或者退出时统一提交。

事务的写法是BEGIN开始、一系列操作、COMMIT提交,出错时ROLLBACK回滚。SimpleSQL 一般提供事务的封装方法,或者你直接执行BEGIN TRANSACTION和COMMIT语句也行。我的经验是:单条实时写入(比如刚发的一条消息)可以不用显式事务,数据库自动提交;批量导入、批量标记已读、批量删除这类操作一定要包事务,否则性能会难看到你想砸键盘。

2.4 字段类型与数据映射的细节

SQLite 是动态类型,字段声明类型只是"亲和性"提示,实际存什么类型由值决定。但这不意味着可以乱来,声明类型还是要规范写,方便自己和后来人阅读,也影响索引和比较行为。整数用INTEGER,字符串用TEXT,浮点用REAL,布尔值用INTEGER存 0/1。

从结果集映射到 C# 对象时,要注意空值处理。SQLite 里字段可以是 NULL,读出来映射到 C# 的int会抛异常,映射到string可能是 null。我的做法是建表时给关键字段加NOT NULL DEFAULT约束,从源头减少空值;读取时对可能为空的字段用可空类型或者给默认值。比如备注字段允许为空,读取时reader.IsDBNull(index) ? "" : reader.GetString(index),这个判断别省,省了迟早崩。

3. 实操过程与核心环节实现

3.1 环境准备与依赖引入

先把 SimpleSQL 的包引入项目。它通常以 UnityPackage 或者源码目录的形式提供,导入后确认程序集能被引用。不同平台需要对应的原生 SQLite 库,桌面平台一般自带,移动平台要确认包里有对应架构的.so或静态库,缺了会在运行时抛DllNotFoundException,这个错误我见过太多次,基本都是原生库没打进去。

引入后写一个数据库管理类,单例或者通过依赖注入持有都行,我倾向单例加懒加载,简单直接。这个类负责三件事:打开连接、执行 SQL、关闭连接。下面是一个基于常见用法的骨架,API 名称按你实际用的版本调整:

using UnityEngine; using System.Collections.Generic; public class LocalDBManager { private static LocalDBManager _instance; public static LocalDBManager Instance => _instance ??= new LocalDBManager(); private object _connection; // 实际类型按 SimpleSQL 的 API 替换 private string _dbPath; public void Init() { _dbPath = System.IO.Path.Combine(Application.persistentDataPath, "social.db"); // 打开连接,具体方法名按封装调整 _connection = OpenConnection(_dbPath); CreateTables(); } private void CreateTables() { // 建表语句见下一节 } }

初始化里先拼路径再打开连接,然后建表。建表用IF NOT EXISTS,重复调用无副作用,这样初始化逻辑可以放心地在每次启动时跑。

3.2 建表语句与索引的完整写法

好友表和聊天记录表的建表 SQL 我整理成下面这样,字段和索引都齐了:

CREATE TABLE IF NOT EXISTS friend ( friend_id TEXT PRIMARY KEY NOT NULL, nickname TEXT NOT NULL DEFAULT '', remark TEXT NOT NULL DEFAULT '', avatar_idx INTEGER NOT NULL DEFAULT 0, group_name TEXT NOT NULL DEFAULT 'default', online INTEGER NOT NULL DEFAULT 0, add_time INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_friend_group ON friend(group_name); CREATE TABLE IF NOT EXISTS chat_message ( msg_id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, sender_id TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', msg_type INTEGER NOT NULL DEFAULT 0, timestamp INTEGER NOT NULL DEFAULT 0, is_read INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_msg_session_time ON chat_message(session_id, timestamp);

这里几个设计点值得展开。friend_id用TEXT PRIMARY KEY,主键自带唯一索引,插入重复 ID 会直接报错,这正好用来做"好友去重"——重复添加同一个好友时捕获异常或者用INSERT OR REPLACE更新。group_name单独建索引,因为按分组拉好友列表是高频操作。聊天记录的联合索引(session_id, timestamp)顺序不能反,反了就只能命中 session_id 那部分,时间排序还得额外排,联合索引的列顺序要严格匹配查询的过滤加排序模式。

msg_id用AUTOINCREMENT,保证自增且不复用已删除的 ID,聊天记录这种需要稳定顺序的场景用它合适。is_read用整型存 0/1,查询未读数就是WHERE session_id = ? AND is_read = 0,配合联合索引效率很高。

3.3 好友列表的增删改查实现

新增好友用参数化插入,重复时更新,INSERT OR REPLACE一行搞定:

INSERT OR REPLACE INTO friend (friend_id, nickname, remark, avatar_idx, group_name, online, add_time) VALUES (?, ?, ?, ?, ?, ?, ?);

参数按顺序绑定,add_time传DateTimeOffset.UtcNow.ToUnixTimeSeconds()得到的整型。这里用 UTC 时间戳而不是本地时间,避免用户改时区导致排序错乱,展示时再转本地时区。

查询全部好友按分组和添加时间排序:

SELECT friend_id, nickname, remark, avatar_idx, group_name, online, add_time FROM friend ORDER BY group_name, add_time DESC;

读取时逐行映射到 C# 对象,注意remark可能为空字符串(建表给了默认值,不会 NULL,但保险起见还是判一下)。删除好友就是DELETE FROM friend WHERE friend_id = ?,更新在线状态就是UPDATE friend SET online = ? WHERE friend_id = ?。这些都是单条操作,不用显式事务。

批量导入好友(比如从服务器同步下来一批)时,把循环插入包进一个事务,性能差距很大。我做过对比,一千条好友逐个插入在低端安卓机上要好几秒,包事务后降到几百毫秒,这个优化不能省。

3.4 聊天记录的写入与分页查询

发一条消息就是一条 INSERT:

INSERT INTO chat_message (session_id, sender_id, content, msg_type, timestamp, is_read) VALUES (?, ?, ?, ?, ?, ?);

timestamp同样用 UTC 整型秒。is_read自己发的消息直接置 1,收到的消息初始 0。

查询某个会话的历史消息,分页是关键。聊天记录可能上万条,一次性全查出来内存和渲染都扛不住。分页用LIMIT加OFFSET,或者更高效的游标方式(用上一页最后一条的 timestamp 做条件):

SELECT msg_id, sender_id, content, msg_type, timestamp, is_read FROM chat_message WHERE session_id = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT ?;

游标分页比OFFSET好在哪?OFFSET越大,数据库要跳过的行越多,深分页时性能下降明显;游标分页每次都是从索引定位,开销稳定。聊天界面往上翻历史就是典型的游标分页场景,第一页传当前时间戳,后续页传上一页最老一条的时间戳。

未读数统计:

SELECT COUNT(*) FROM chat_message WHERE session_id = ? AND is_read = 0;

标记已读:

UPDATE chat_message SET is_read = 1 WHERE session_id = ? AND is_read = 0;

这个 UPDATE 可能影响很多行,包事务更稳。

3.5 会话列表的聚合查询

聊天主界面要展示会话列表,每个会话显示最后一条消息和未读数。这个查询稍微复杂点,用分组聚合:

SELECT session_id, MAX(timestamp) AS last_time, SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS unread_count FROM chat_message GROUP BY session_id ORDER BY last_time DESC;

拿到每个会话的最后时间和未读数后,再按last_time查最后一条消息的内容(或者用窗口函数,但 SQLite 老版本对窗口函数支持有限,稳妥起见分两步查)。这个聚合查询在数据量大时要注意,GROUP BY会扫全表,如果会话数量不多但消息量大,可以考虑单独维护一张会话表,每次发消息时更新会话表的最后消息和未读数,用空间换时间。我一般消息量过万才做这个优化,小规模直接聚合够用。

4. 常见问题与排查技巧实录

4.1 数据库文件损坏与恢复

移动端最怕的是应用被杀时正在写数据库,导致文件损坏。SQLite 有 WAL 和回滚日志机制,正常情况能自愈,但极端情况(存储满、断电)仍可能损坏。表现是打开时报database disk image is malformed。

排查思路:先确认是不是真的损坏,用PRAGMA integrity_check检查。如果损坏,能救则救(导出能读的数据重建库),不能救就删库重建,但要接受数据丢失。预防手段比补救重要:写入用事务保证原子性、避免在存储空间不足时大量写入、关键数据定期导出备份。我一般会在设置里加一个"导出聊天记录"的功能,既是用户需求也是兜底备份。

4.2 常见问题速查表

下面这张表是我在实际项目里踩过的坑和对应解法,按现象查原因比较快:

现象可能原因解决方向
运行时抛 DllNotFoundException原生 SQLite 库未打进目标平台检查包内各架构原生库是否齐全
查询结果为空但数据确实存在参数绑定顺序错或类型不匹配核对占位符顺序,整型别传字符串
批量插入极慢未包事务,每条都 fsync用 BEGIN/COMMIT 包住批量操作
深分页越来越卡用 OFFSET 跳大量行改游标分页,按 timestamp 定位
中文乱码连接未指定 UTF-8 或字段类型错确认 TEXT 类型与编码设置
应用重启后数据丢失数据库路径用了非持久化目录改用 persistentDataPath
并发读写报 database is locked多连接同时写单连接串行化,或加重试与超时

4.3 并发与锁的实操心得

SQLite 是文件级锁,同一时刻只允许一个写操作。单机游戏里通常只有一个数据库连接,问题不大,但如果你在多个线程里读写(比如后台线程加载聊天记录),就可能撞上database is locked。

我的处理原则是:所有数据库操作收敛到一个连接、一个线程上执行。UI 线程发起请求,通过一个队列或者任务调度器串行执行,避免并发。如果确实需要后台加载,用只读连接加PRAGMA query_only,读操作之间可以并发,写操作单独排队。另外给连接设置合理的 busy timeout(比如 3000 毫秒),遇到锁时自动等待重试,比直接报错友好。

提示:不要在主线程做大批量数据库操作,几千条记录的查询和映射会卡帧。把批量读写放到后台线程,结果回主线程更新 UI,这个模式在聊天记录加载时尤其重要。

4.4 数据迁移与版本管理

表结构不是一成不变的,后面可能要加字段(比如给好友加"是否置顶"、给消息加"撤回状态")。SQLite 支持ALTER TABLE ADD COLUMN,加字段很轻量,但删改字段麻烦。我的做法是在数据库里维护一个版本号(用PRAGMA user_version或者单独一张元数据表),启动时读版本号,低于当前代码版本就执行对应的迁移脚本。

迁移脚本要幂等、要能重复执行不出错,加字段前先判断字段是否存在(查PRAGMA table_info)。这个机制一开始就搭好,后面加需求时能省大量返工。我见过项目前期没做版本管理,后期加字段时老用户升级直接崩,只能让用户清数据重来,体验极差。

4.5 性能调优的几个实用开关

SQLite 有几个 PRAGMA 对性能影响明显,按场景调:

  • PRAGMA journal_mode = WAL;写前日志模式,读写并发更好,适合聊天这种读多写多的场景,但会多出-wal和-shm文件。
  • PRAGMA synchronous = NORMAL;降低同步频率换性能,崩溃时可能丢最后几条写入,聊天记录能接受这个取舍;如果数据极其重要用FULL。
  • PRAGMA cache_size = -2000;设置缓存大小(负值表示 KB),给个 2MB 左右对中小数据量够用。

这些开关在打开连接后立即执行。WAL 模式在移动端要注意,应用被杀时 WAL 文件需要能正确合并,正常关闭连接会处理,所以前面强调的关闭连接不能省。

4.6 一个容易忽略的细节:时间戳精度

最后说个细节。时间戳用秒还是毫秒?秒级精度在聊天场景可能不够——同一秒内发多条消息,排序会不稳定,ORDER BY timestamp出来的顺序可能是插入顺序的倒置。解决办法有两个:用毫秒时间戳,或者排序时加msg_id作为次级排序键ORDER BY timestamp DESC, msg_id DESC。我倾向后者,因为自增 ID 天然反映插入顺序,加个次级排序键就能保证稳定,不用改时间戳精度。这个坑很隐蔽,测试时同一秒连发几条消息才会暴露,提前加上能省一次返工。

5. 数据安全与清理策略

5.1 敏感数据的本地存储边界

好友和聊天记录属于用户隐私数据,本地存储要考虑基本的安全边界。SQLite 文件本身是明文的,任何能访问设备文件系统的人都能读。如果应用对隐私要求高,可以考虑对消息内容做应用层加密再入库,密钥存在系统安全存储里。不过对大多数单机游戏来说,这个成本收益比要权衡——加密会增加每次读写的 CPU 开销,聊天记录量大时影响明显。

我的折中做法是:普通聊天内容明文存,涉及账号凭证、支付相关的绝不进这个库。数据库文件放在应用私有目录(persistentDataPath本身就是私有的),不导出到公共存储,这样已经挡住了大部分非 root 设备的越权访问。真要加密,只加密最敏感的字段,别整库加密,整库加密后连索引都用不上,查询性能会崩。

5.2 聊天记录的清理与归档

聊天记录无限增长是迟早的事,得有清理策略。常见做法是按时间或按数量保留,比如每个会话只保留最近一千条,超出的删掉或者归档到单独的表。删除时注意别用DELETE FROM chat_message WHERE timestamp < ?这种大范围删除,它会锁表很久,分批删(每次删几百条,循环直到删完)对 UI 更友好。

归档的话,把老消息移到chat_message_archive表,主表只留热数据,查询历史时先查主表再查归档表。这个设计在消息量大的应用里很常见,但会增加查询复杂度,小项目直接删旧数据更省事。我一般按产品需求定:如果用户明确要"永久保留聊天记录",就做归档;如果只是临时会话,定期清理即可。

5.3 数据库体积控制

SQLite 删除数据后文件不会自动缩小,会留下空洞。长期运行后文件可能比实际数据大很多。用VACUUM命令可以重建文件、回收空间,但它会锁库且耗时,别在游戏运行时频繁调用。我的做法是在应用启动时判断,如果空闲页比例高(查PRAGMA freelist_count和PRAGMA page_count算比例),在加载界面执行一次VACUUM,用户感知不到。

另外,消息内容如果很长(比如富文本、base64 图片),考虑把大字段拆到单独的附件表,主表只存引用,这样主表的行更小,索引和查询都更快。这个优化在纯文本聊天里用不上,但一旦支持图片消息就该考虑。

6. 从零复现的完整落地清单

6.1 分步实施顺序

把上面的内容串成一条可执行的路径,按这个顺序做不会乱:

  1. 引入 SimpleSQL 及对应平台原生库,确认能编译运行。
  2. 写数据库管理类,初始化时拼persistentDataPath路径、打开连接、执行建表与索引语句。
  3. 打开连接后立即执行性能 PRAGMA(WAL、synchronous、cache_size)。
  4. 实现好友表的增删改查,先跑通单条插入和查询。
  5. 实现聊天记录的插入和游标分页查询,验证排序稳定性。
  6. 实现会话列表聚合查询和未读数统计。
  7. 加事务封装,把批量操作改造成事务版本。
  8. 加版本号与迁移机制,为后续加字段留口子。
  9. 加清理策略和 VACUUM 时机。
  10. 在真机上跑一遍完整流程,重点测低端机性能和异常退出后的数据一致性。

每一步做完都验证一下,别攒到最后一起调。数据库问题越早暴露越好定位,等业务逻辑堆上来再查,光理清哪条 SQL 出错就要半天。

6.2 验证与自测要点

自测时重点盯这几个场景:同一秒连发多条消息看排序是否稳定;批量导入一千条好友看耗时;杀掉进程再启动看数据是否完整;存储空间接近满时写入看是否优雅报错;切换时区后看时间显示是否正确。这几个场景覆盖了排序、性能、持久化、异常、时区五类高频问题,跑通了基本就稳了。

我在实际项目里最大的体会是,本地数据库这东西,前期多花半天把表结构、索引、事务、版本管理这四件事做扎实,后期能省掉无数个加班的夜晚。尤其是索引和事务,很多人觉得数据量小无所谓,等数据量上来再补,往往要动表结构甚至迁移数据,代价比一开始就做对高得多。另外那个时间戳排序加次级键的小技巧,看着不起眼,但它是那种"不加迟早出事、加了永远不出事"的防御性设计,建议直接抄进你的建表和查询里。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进