十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity3D 集成 SQLite 从零到数据可视化:本地存储与查询优化指南

Unity3D 集成 SQLite 从零到数据可视化:本地存储与查询优化指南 这件事得从我自己踩过的一个坑说起。之前做一款单机小游戏玩家存档、道具数据、关卡进度全用 JSON 文件堆在 persistentDataPath 里一开始确实省事但随着版本迭代数据表越加越多查询要写一堆循环嵌套字段改了还要对着旧存档写迁移逻辑整个人快到崩溃边缘。后来换了 SQLite 做本地存储配合外部的 DB Browser 工具查看数据开发效率直接上了一个台阶。这篇就把 Unity3D 集成 SQLite 从零到数据可视化的完整链路拆开讲包括底层原理、跨平台部署、封装思路、UGUI 数据绑定以及图表绘制方案。这篇内容适合的读者大概是这几类一是项目已经开始用 PlayerPrefs 和 JSON 存数据、但明显感觉到维护成本变高的 Unity 开发者二是刚接触数据库概念、想在游戏项目里试试 SQLite 的初学者三是做数据看板、本地工具类应用、需要把数据展示成图表的同学。我会尽量把每一步的为什么讲清楚而不是只丢代码。1. 为什么 Unity 的项目需要 SQLite本地存储方案对比与适用边界1.1 PlayerPrefs 和 JSON 的瓶颈在哪先说一个经常被忽略的事实PlayerPrefs 本质是键值存储适合存音量、语言设置这种零散配置一旦数据变成行和列的结构它就变得非常难用。比如你要存一个排行榜每位玩家有昵称、分数、游戏时长、最后登录时间PlayerPrefs 的写法大概是PlayerPrefs.SetString(player_1_name, 张三)然后写个循环去拼 key。查询前三名写循环全部读出来手动排序。批量删除某个日期之前的数据抱歉你得遍历所有 key还得设计一套 key 命名规则。这还不算后续加字段时的迁移问题。JSON 文件方案比 PlayerPrefs 强一些至少能把数据结构化但也不解决查询这个核心问题。一次加载几千条记录到内存再用 LINQ 做过滤和排序开发期数据量小感觉不到问题等到用户数据积累到几万条每次加载几百毫秒的卡顿就来了。而且多端同步写同一个 JSON 文件还会有并发写损坏的风险。SQLite 作为嵌入式关系型数据库正好补上这块短板——它不依赖服务器一个文件就是一个库支持标准 SQL 查询事务机制保证写入安全可以说是 Unity 本地结构化存储的首选。1.2 SQLite 适合 Unity 的四个理由第一是零运维成本。SQLite 是进程内数据库Unity 应用本身就是一个进程直接调用原生库读写文件不需要搭建独立的数据库服务。对单机游戏、本地工具类应用来说这是最大的优势。第二是标准 SQL 带来的表达能力。关联查询、聚合函数、条件过滤、排序分页都由数据库引擎完成应用层代码量大幅减少。同样是取排行榜前三名一句SELECT * FROM player ORDER BY score DESC LIMIT 3就结束了。第三是跨平台一致性。Windows、macOS、Linux、Android、iOS 都有对应的 SQLite 原生实现只要处理好各平台的动态库/静态库同一套 SQL 逻辑在所有平台行为一致。这一点比嵌入其他重型数据库要省心得多。第四是数据可审查。开发阶段直接用 DB Browser for SQLite 或 SQLiteStudio 打开打包好的数据库文件数据到底长什么样一目了然比对着 JSON 文件猜结构强太多了。你甚至可以修改数据后重新启动游戏做测试这在调数值、复现 bug 时非常实用。1.3 什么场景不建议用 SQLite需要说实话SQLite 不是万能的。如果你做的是大型联网游戏服务端数据需要多人实时共享那 SQLite 并不合适这是服务器关系型数据库的领域如果你的数据本身就是深层次的嵌套结构比如一棵复杂的技能树每个节点有不同数量的子节点硬要用 SQLite 表来存反而别扭这时候 JSON 文档或者 NoSQL 类方案体验更好。SQLite 最适合的是本地存在、结构相对规整、需要灵活查询的数据把握住这个边界你才知道什么时候该用它。2. 环境搭建与原生库处理三分钟让 Unity 跑通 SQLite2.1 两种接入路线Mono.Data.Sqlite 和 sqlite-netUnity 工程接入 SQLite 有两条主流路线。一条是使用 Unity 自带的Mono.Data.Sqlite程序集配合平台原生的 sqlite3 动态库另一条是使用第三方的纯 C# 封装库比如 sqlite-net它在底层同样依赖原生 sqlite3只是把 API 封装得更友好支持 LINQ 表达式和 POCO 映射。我个人的建议是如果你只是想快速做一个小工具或者对 SQL 不熟用 sqlite-net 挺舒服如果你希望更贴近数据库本质、方便排查问题或者后续要执行复杂的 SQL 语句我推荐直接用 Mono.Data.Sqlite。这篇文章以 Mono.Data.Sqlite 为主线来讲因为它的逻辑更贴近原生 SQL 操作理解了这套换其他封装库也只是换个壳。2.2 Windows 平台下最容易被忽略的 dll 配置先说 Windows 编辑器环境下最容易踩的坑。Unity 自带的Mono.Data.Sqlite.dll引用可以在编辑器的 Scripting API 里直接找到但运行时会去加载一个原生动态库sqlite3.dll这个文件 Unity 默认不打包你需要自己找对应架构的版本否则运行时报的错非常经典DllNotFoundException: sqlite3这个报错几乎是每个初接 SQLite 的 Unity 开发者都会遇到的。正确的处理方式是先确认你的 Unity 编辑器是 64 位还是 32 位新版本基本都是 64 位然后下载对应版本的sqlite3.dll放到Assets/Plugins/x86_64目录下64 位如果有 32 位需求就放到Assets/Plugins/x86。Inspector 面板里的 Platform Settings 要勾选对应平台这样 Unity 才能在打包时自动带上正确的原生库。这里要注意一个细节网上很多教程给的 dll 下载地址来路不明版本良莠不齐。稳妥的方式是去 SQLite 官方网站下载预编译好的二进制包或者安装System.Data.SQLite后到安装目录里翻出 sqlite3.dll。另外如果你同时开发 Android 和 Windows记得给不同平台指定不同的文件导入设置避免把 Windows 的 dll 打进了 Android 包。2.3 Android / iOS 平台的注意事项移动平台上就要更谨慎了。Android 系统自带 libsqlite.so但 Unity 的 Mono.Data.Sqlite 不一定能直接访问系统库常见做法是通过 Android NDK 编译出libsqlite3.so或者 libsqlite.so放到Assets/Plugins/Android目录下。iOS 更直接因为 iOS 不允许动态链接库SQLite 需要以静态库方式编进应用常见做法是把 sqlite3 的源码或者编译好的.a静态库加进 Xcode 工程。这部分网上有现成的第三方集成库比如 SQLite4Unity3d已经处理好了各平台的原生依赖如果你不想被 dll 折磨直接用这类现成封装是性价比很高的选择。另外不管哪个平台运行时都有两个关键的目录概念Application.streamingAssetsPath是只读目录适合放初始数据库模板Application.persistentDataPath是可读写目录是应用沙盒内真正能存数据的路径。你的数据库连接串应该指向 persistentDataPath如果首次运行需要初始数据再从 StreamingAssets 拷贝过去。这个拷贝逻辑我在后面封装类里会给完整代码。3. 数据库访问层封装连接、增删改查与自增 ID3.1 数据库文件路径与部署策略在动手写代码之前先明确数据文件的生命周期。开发期你可以让数据库直接生成在 persistentDataPath 下这样在编辑器里用 DB Browser 打开对应路径的 .db 文件就能直接查看。发布期如果你希望用户首次启动就有一个带基础数据的库比如游戏配置表就在 StreamingAssets 放一份只读模板首次运行时检查 persistentDataPath 是否存在不存在则拷贝。这段逻辑写进一个单例或者静态初始化方法里会比较干净。我习惯的做法是封装一个SQLiteHelper类构造时传入文件名内部做路径拼装、拷贝检查和连接打开。这样一来其他脚本只需要关心数据库操作不关心文件从哪来、往哪存。3.2 核心封装类连接管理、查询、非查询、标量下面这段是我在生产环境里用过的 SQLiteHelper 精简版包含连接管理、执行非查询语句增删改、执行查询语句返回 List 字典、执行标量查询取单值这几个最常用的方法using System; using System.Collections.Generic; using System.IO; using Mono.Data.Sqlite; using UnityEngine; public class SQLiteHelper : IDisposable { private SqliteConnection _connection; private string _dbPath; public SQLiteHelper(string dbFileName) { string dbDir Application.persistentDataPath; _dbPath Path.Combine(dbDir, dbFileName); // 如果 streamingAssets 里有同名模板首次运行拷贝到可写目录 string templatePath Path.Combine(Application.streamingAssetsPath, dbFileName); if (!File.Exists(_dbPath)) { if (File.Exists(templatePath)) { File.Copy(templatePath, _dbPath); } else { SqliteConnection.CreateFile(_dbPath); } } _connection new SqliteConnection(URIfile: _dbPath); _connection.Open(); } public int ExecuteNonQuery(string sql, params SqliteParameter[] parameters) { using (SqliteCommand cmd _connection.CreateCommand()) { cmd.CommandText sql; if (parameters ! null) { cmd.Parameters.AddRange(parameters); } return cmd.ExecuteNonQuery(); } } public object ExecuteScalar(string sql, params SqliteParameter[] parameters) { using (SqliteCommand cmd _connection.CreateCommand()) { cmd.CommandText sql; if (parameters ! null) { cmd.Parameters.AddRange(parameters); } return cmd.ExecuteScalar(); } } public ListDictionarystring, object ExecuteQuery(string sql, params SqliteParameter[] parameters) { var rows new ListDictionarystring, object(); using (SqliteCommand cmd _connection.CreateCommand()) { cmd.CommandText sql; if (parameters ! null) { cmd.Parameters.AddRange(parameters); } using (SqliteDataReader reader cmd.ExecuteReader()) { while (reader.Read()) { var row new Dictionarystring, object(); for (int i 0; i reader.FieldCount; i) { row[reader.GetName(i)] reader.GetValue(i); } rows.Add(row); } } } return rows; } public void Dispose() { if (_connection ! null) { _connection.Close(); _connection.Dispose(); _connection null; } } }这段代码有几个设计细节值得说明。第一构造函数的模板拷贝逻辑区分了StreamingAssets和persistentDataPath避免每次启动都覆盖用户已写入的数据。第二所有数据库操作都用using语句包裹SqliteCommand确保命令对象及时释放。第三ExecuteQuery返回的是ListDictionarystring, object牺牲了一点类型安全换来了通用性——不管查哪张表都能用同一套方法拿到结果再在业务层做类型转换。使用的时候可以在某个管理脚本里持有这个 helper 的单例应用退出时释放连接。如果你在协程里频繁访问数据库建议加一个简单的锁或者排队机制SQLite 连接本身支持并发读但多线程同时写可能会有锁冲突。3.3 插入数据后如何正确拿到自增 ID这个点是热词里反复出现的sqlite insert into 后获取自动序号属于 SQLite 使用者的高频疑惑。在 SQLite 里自增主键通常这样建表CREATE TABLE IF NOT EXISTS player ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Name TEXT NOT NULL, Score INTEGER DEFAULT 0, PlayTime REAL DEFAULT 0, CreateTime TEXT DEFAULT (datetime(now,localtime)) );插入之后拿自增 ID 的正确姿势不是SELECT MAX(Id)而是调用last_insert_rowid()。为什么不用MAX(Id)因为并发场景下多个连接同时插入时MAX 查到的不一定是当前这条记录而last_insert_rowid()是当前连接上下文的会话级信息准确又高效。等价的 C# 写法是int InsertAndGetId(SQLiteHelper db, string name, int score) { db.ExecuteNonQuery( INSERT INTO player(Name, Score) VALUES(name, score), new SqliteParameter(name, name), new SqliteParameter(score, score)); long lastId Convert.ToInt64(db.ExecuteScalar(SELECT last_insert_rowid();)); return (int)lastId; }注意这里参数化查询的使用。永远不要通过拼接字符串的方式把用户输入塞进 SQL尤其当数据来自外部输入时参数化是防止 SQL 注入的基本防线。在 Unity 里这一条容易被忽略因为很多人觉得单机游戏不存在注入风险但万一你的数据将来要同步到服务端这就会变成漏洞。3.4 批量写入性能事务才是关键还有一个高频场景是批量写入比如游戏开始时要初始化几千条配置数据到数据库。如果逐条INSERT每条都要单独走一次磁盘提交耗时能到几百毫秒甚至几秒。正确做法是开启一个事务把批量插入包在事务里提交时一次性落盘。SQLite 对事务内插入的优化非常明显我实测过一万条数据逐条插入大概需要 2 到 3 秒事务包起来之后能压到一百毫秒以内。在封装类里加一个方法封装事务比较方便public void ExecuteTransaction(ActionSqliteConnection action) { using (SqliteTransaction transaction _connection.BeginTransaction()) { try { action(_connection); transaction.Commit(); } catch { transaction.Rollback(); throw; } } }调用处传入一个委托在委托内部执行多条插入语句。这样既保证了性能也保证了要么全部成功、要么全部回滚的一致性。需要注意事务委托里不要再通过 helper 的成员方法执行查询因为嵌套命令可能会因为活动事务冲突而报错直接在action里用传入的连接创建命令即可。3.5 数据类型映射与 NULL 处理Unity 的 C# 类型和 SQLite 的存储类型不是完全等价的。SQLite 有 INTEGER整型、REAL浮点、TEXT文本、BLOB二进制四种存储类型从SqliteDataReader读出来的值默认可能是long、double、string或者byte[]。这就导致一个常见问题当你用int.Parse(row[Id].ToString())转换其实没必要先转字符串直接用Convert.ToInt32(row[Id])更高效。布尔值也要特别注意——SQLite 没有原生的布尔类型通常用 INTEGER 的 0/1 表示读出来后是long不能直接强转bool。此外SQLite 允许 NULL 值当你查询某个可空字段时C# 侧拿到的可能是DBNull.Value直接调用.ToString()不会崩但取.ToString()之外的方法就可能报错。我在查询解析时习惯加一个辅助函数private T GetValueT(Dictionarystring, object row, string column) { object val row[column]; if (val null || val DBNull.Value) { return default(T); } return (T)Convert.ChangeType(val, typeof(T)); }这个辅助函数在处理查询结果时非常省心值得加入你的工具类里。4. 从数据库到界面排行榜列表的完整绑定链路4.1 数据模型定义与查询设计数据库显然不是用来存了看热闹的最终要驱动界面。我们以排行榜为例把数据从库到 UI这条链路完整走一遍。先定义一个数据模型类对应 player 表[Serializable] public class PlayerRecord { public int Id; public string Name; public int Score; public float PlayTime; public string CreateTime; }然后写一个查询方法按分数降序取前 10 名public ListPlayerRecord GetTopPlayers(int count) { var rows _db.ExecuteQuery( SELECT Id, Name, Score, PlayTime, CreateTime FROM player ORDER BY Score DESC, PlayTime ASC LIMIT count, new SqliteParameter(count, count)); var players new ListPlayerRecord(); foreach (var row in rows) { players.Add(new PlayerRecord { Id GetValueint(row, Id), Name GetValuestring(row, Name), Score GetValueint(row, Score), PlayTime GetValuefloat(row, PlayTime), CreateTime GetValuestring(row, CreateTime) }); } return players; }注意这里不要SELECT *显式列出字段名有两个好处一是代码可读性强后续表结构变更时能迅速定位影响二是避免BLOB大字段被无谓地读出来拖累查询性能。4.2 UGUI 列表的动态生成ScrollRect Content Item在 Unity 的 UGUI 体系里动态数据列表的标准做法是一个ScrollRect负责滚动区域一个Content节点承载所有列表项每个列表项是一个预制体。运行时先清空 Content 下的所有子节点再遍历查询结果动态实例化。以排行榜为例列表项预制体里至少有三个 Text排名、昵称、分数。生成逻辑大致如下public Transform contentRoot; public GameObject itemPrefab; public void RefreshLeaderboard() { // 清空旧节点 for (int i contentRoot.childCount - 1; i 0; i--) { Destroy(contentRoot.GetChild(i).gameObject); } var topPlayers GetTopPlayers(10); for (int i 0; i topPlayers.Count; i) { GameObject item Instantiate(itemPrefab, contentRoot); var texts item.GetComponentsInChildrenText(); texts[0].text (i 1).ToString(); texts[1].text topPlayers[i].Name; texts[2].text topPlayers[i].Score.ToString(); } }这个做法垂直场景下简单可靠。但有一个高频问题当列表项数量上百时每次刷新都创建销毁所有预制体会产生性能尖峰。如果要做的列表经常刷新最好引入对象池。基础的池化方案就是准备一个 Stack 存放回收的 Item刷新时优先从池里取不取就实例化而不再用的 Item 不是Destroy而是SetActive(false)入池。这套思路和游戏里的子弹池类似。4.3 数据变更后的刷新时机在数据变更后刷新界面是业务里最容易出 bug 的地方。常见陷阱是你在某个按钮的点击事件里往数据库插入了一条数据然后立刻调用刷新函数却发现界面没变化。原因一般不是数据没写进去而是你刷新时用了旧的数据源缓存。我的建议是所有 UI 展示都从数据库实时查询不要维护一份缓存列表。数据量不大时每次都走一次查询代码逻辑简单且不会出现缓存和数据库不一致的问题。如果确实需要缓存那就要设计一套明确的失效机制——数据变更时手动清缓存。做一个简单的数据访问层所有写操作集中在某个服务类里刷新函数在写操作完成后被调用这是最稳妥的做法。5. 数据可视化在 Unity 里画柱状图和折线图5.1 用 Image 尺寸变化做基础柱状图数据可视化听起来很高级但在 Unity 里做基础图表并不需要引入第三方插件。用 UGUI 的Image组件配合RectTransform就可以实现最常用的柱状图。核心思路很简单柱状图的每一根柱子就是一个 Image一列的宽度固定高度按数据值等比缩放。具体实现分成三步。第一步创建ChartContainer节点挂一个VerticalLayoutGroup用来等间距排列柱子。第二步在代码里生成指定数量的柱子 Image每个柱子的大小通过RectTransform.sizeDelta控制。第三步计算数据百分比float maxVal 100f; // 业务里改成实际最大值 float percent value / maxVal; float maxHeight 300f; float barHeight percent * maxHeight; barRect.sizeDelta new Vector2(barWidth, barHeight);这里有个细节如果柱子的锚点默认在中心改变 height 时柱子会向上下两端同时扩展视觉上底部会漂移。解决方式是让柱子 Image 的锚点落在容器底部anchorMin 和 anchorMax 的 y 都设为 0再控制 pivot 的 y 为 0。这样改 height 时柱子会从底部往上长效果才正常。给柱子加颜色和标签也很容易在柱子 Image 子节点挂一个 Text显示数值。这样一张基础柱状图就成型了遇到数据变化时重跑一次生成逻辑即可。5.2 折线图的画法从点连线到线渲染折线图的实现比柱状图稍复杂一点。需要先根据数据生成一系列坐标点再把点连成线。在 UGUI 里画折线最简单的方案是给 Canvas 对象挂一个LineRenderer设置使用世界坐标然后按屏幕坐标换算成世界坐标去设置点位。但 LineRenderer 是 3D 渲染组件在 UI 界面里要注意层级和遮挡可能需要一个专门的 Canvas 把Render Mode设为Screen Space - Overlay并把线渲染出来。另一种更 UI 原生的方式是点 线段拼接把相邻两个数据点用一张细长的 Image 连接旋转角度等于两点连线的斜率角度。虽然 API 调用量不小但控制力很强适合需要交互效果的场景。大致思路是Vector2 start GetChartPoint(index); Vector2 end GetChartPoint(index 1); GameObject line Instantiate(linePrefab, chartRoot); line.transform.position (start end) / 2f; float angle Mathf.Atan2(end.y - start.y, end.x - start.x) * Mathf.Rad2Deg; line.transform.rotation Quaternion.Euler(0, 0, angle); line.transform.localScale new Vector3(Vector2.Distance(start, end), 1, 1);实际操作时还要考虑单位换算。假设数据范围是 0 到 100图表区域像素宽度是 800、高度是 400那么GetChartPoint要把归一化坐标映射到图表区域内。这块公式不复杂但一定要保证换算一致避免多个图表之间坐标体系混乱。5.3 替代方案导出数据给 ECharts 或使用 Unity 图表插件如果你的需求不是游戏内嵌图表而是做一个数据可视化大屏风格的展示界面那还有两条路可以考虑。一条是把 SQLite 里的数据按需导出成 JSON 或 CSV在 Web 端用 ECharts 等成熟可视化库呈现。很多项目里做本地报表就是先生成本地 JSON 文件再用浏览器打开一个网页模板完成展示优势是图表类型丰富、样式美观、实现成本极低。另一条路是直接使用 Unity 生态的图表插件比如 Graph And Chart 或 XCharts。XCharts 是国产开源库支持曲线图、柱状图、饼图、热力图等常见类型API 设计贴近中文开发者习惯文档也很全。如果你要做的可视化界面复杂、需要很多交互缩放、提示框、动态刷新用现成图表插件比自己用 Image 拼要省力得多。我的经验是简单柱状图用 Image 方案最快复杂图表直接上插件不要重复造轮子。6. 开发调试工具与高频踩坑清单6.1 DB Browser for SQLite开发者的数据库放大镜在开发阶段我强烈建议把 DB Browser for SQLite 放进自己的工具链。它是一款免费开源的 SQLite 可视化工具可以浏览表、编辑数据、执行 SQL 语句、导入导出 CSV功能相当齐全。打开 persistentDataPath 里生成的数据库文件你能直接看到所有表的内容这在调试数据写入逻辑时非常高效。一个很实用的工作流是先在 DB Browser 里手工创建表、插入几行测试数据保存为模板数据库放到 StreamingAssets这样 Unity 首次运行就会拷贝一份带初始数据的库到 persistentDataPath。这比在 Unity 代码里写一大串建表语句更直观省事。6.2 SQLiteStudio 和 DBeaver 怎么选除了 DB Browser for SQLite同类工具还有 SQLiteStudio 和 DBeaver。SQLiteStudio 的特点是轻量绿色版免安装界面简洁适合快速查看单个库文件。DBeaver 则是一个大而全的数据库管理工具不仅支持 SQLite还支持 MySQL、PostgreSQL、Oracle 等主流数据库。如果你的项目后期会对接服务器数据库装一个 DBeaver 就能统一管理多处数据源。个人建议日常快速查看用 DB Browser for SQLite 就好它针对 SQLite 的优化最到位如果涉及跨数据库类型的项目再考虑 DBeaver。这类工具没有绝对的好坏关键是形成稳定的工作流别在几个工具之间反复横跳。6.3 运行时报错排查清单我在接 SQLite 的过程中积累了一份高频报错清单列在这里供参考报错信息常见原因解决方案DllNotFoundException: sqlite3原生动态库缺失或平台不对将对应平台架构的 sqlite3.dll 放入 Plugins 目录并检查导入设置Access to the path is denied尝试写入 StreamingAssets 只读目录数据写入路径改用 persistentDataPathdatabase is locked多个连接同时写库或者连接未释放写操作加锁或串行化用完连接及时 DisposeNo such table: xxx建表语句未执行或模板库和代码表结构不同步检查初始化流程用 DB Browser 确认表是否存在Cannot Open Database连接串格式错误或目录不存在确认URIfile:前缀和目录已创建另外要特别强调一点Unity 的 Android 打包时会遇到Mono.Data.Sqlite在某些版本上不被正确链接的问题表现是编辑器中一切正常打包后运行就报找不到类型或找不到 sqlite3。这类问题很难通过改代码解决基本是原生依赖和 IL2CPP 编译器的兼容性问题。遇到这种情况最省时间的方案是切换到 sqlite-net 或官方维护的第三方封装然后按对应文档重新配置原生库。还有一个我踩过的坑Application.streamingAssetsPath在 Android 平台上是一个位于 APK 内部的压缩路径不能直接用File.Exists和File.Copy去判断和拷贝。正确的做法是先把 StreamingAssets 里的文件字节读出来再写入 persistentDataPath。具体可以用UnityWebRequest加载staramingAssetsPath 文件名拿到字节后File.WriteAllBytes。这也是为什么很多 SQLite 集成教程里会单独强调移动端初始化逻辑。6.4 数据库结构变更后的迁移策略最后聊一个很容易被忽略的问题数据库表结构版本迁移。你的游戏发布 1.0 版本时数据库有 3 个字段到了 1.1 想加 1 个字段如果用户手机里已经有旧数据库直接执行CREATE TABLE IF NOT EXISTS并不会给旧表加字段。这时就需要一个轻量级的迁移机制。我的做法是在数据库初始化时维护一个PRAGMA user_version每发布一个新版本就在代码里检查当前版本号执行对应的ALTER TABLE语句后再更新版本号。例如long currentVersion Convert.ToInt64(db.ExecuteScalar(PRAGMA user_version;)); if (currentVersion 2) { db.ExecuteNonQuery(ALTER TABLE player ADD COLUMN Level INTEGER DEFAULT 1;); db.ExecuteNonQuery(PRAGMA user_version 2;); }这个机制虽然简单但能避免上线后为什么老用户数据不见了这种诡异问题。注意ALTER TABLE加字段时新字段必须允许 NULL 或者有 DEFAULT 值否则已有行会因为缺值而执行失败。做完这一整套链路你会发现游戏的本地数据管理从遍地 PlayerPrefs JSON 文件变成一套真正有结构、可查询、可调试、可展示的数据库体系。从开发期到维护期省下来的时间比学 SQLite 本身的时间多得多。我个人的体会是只要能迈过前面平台库配置那道坎SQLite 在 Unity 里的价值回报率非常高。最后建议你拿到代码后先不要急着接入业务单独建个工程把增删改查和 UI 列表跑通再去替换现有存储层这样心态会稳很多。
返回列表