资讯动态

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

发布时间:2026/10/27 20:54:46 来源:尧图企业网站定制
好友列表和聊天记录这两块数据放在单机游戏或者弱联网应用里最省事的做法就是直接落到本地数据库。我最早做这类需求的时候图快用 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 聊天记录的写入与分页查询发一条消息就是一条 INSERTINSERT 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 分步实施顺序把上面的内容串成一条可执行的路径按这个顺序做不会乱引入 SimpleSQL 及对应平台原生库确认能编译运行。写数据库管理类初始化时拼persistentDataPath路径、打开连接、执行建表与索引语句。打开连接后立即执行性能 PRAGMAWAL、synchronous、cache_size。实现好友表的增删改查先跑通单条插入和查询。实现聊天记录的插入和游标分页查询验证排序稳定性。实现会话列表聚合查询和未读数统计。加事务封装把批量操作改造成事务版本。加版本号与迁移机制为后续加字段留口子。加清理策略和 VACUUM 时机。在真机上跑一遍完整流程重点测低端机性能和异常退出后的数据一致性。每一步做完都验证一下别攒到最后一起调。数据库问题越早暴露越好定位等业务逻辑堆上来再查光理清哪条 SQL 出错就要半天。6.2 验证与自测要点自测时重点盯这几个场景同一秒连发多条消息看排序是否稳定批量导入一千条好友看耗时杀掉进程再启动看数据是否完整存储空间接近满时写入看是否优雅报错切换时区后看时间显示是否正确。这几个场景覆盖了排序、性能、持久化、异常、时区五类高频问题跑通了基本就稳了。我在实际项目里最大的体会是本地数据库这东西前期多花半天把表结构、索引、事务、版本管理这四件事做扎实后期能省掉无数个加班的夜晚。尤其是索引和事务很多人觉得数据量小无所谓等数据量上来再补往往要动表结构甚至迁移数据代价比一开始就做对高得多。另外那个时间戳排序加次级键的小技巧看着不起眼但它是那种不加迟早出事、加了永远不出事的防御性设计建议直接抄进你的建表和查询里。

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

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

免费获取报价 →
↑