之前在搞一个游戏内的运营数据统计模块,需要本地存一批战斗记录和玩家行为数据,还要按条件查询、做聚合统计。刚开始图省事,直接用 PlayerPrefs 存键值对,数据量一大、字段一复杂,读写和解析都让人头疼。后来切换到了 SQLite,才发现“单 DB 文件、SQL 查询、跨平台支持”这三个特性,简直是为 Unity 本地数据存储量身定做的。这篇文章我就完整记录一下,从零开始把 SQLite 集成进 Unity,再把数据库里的数据做成可视化图表的全过程,包括插件选型、代码封装、移动端适配和一堆实战中踩过的坑。
如果你也有类似需求——不管是做单机游戏存档、玩家战绩统计、关卡编辑器配置读取,还是搞一个本地数据可视化看板,这篇文章应该能帮你少走不少弯路。我会把每一步为什么这么做、参数怎么选、坑在哪里都说清楚,直接照着抄作业即可。
1. 项目概述与整体设计思路
先说清楚这个项目到底要干什么。我的目标是做一个 Unity 客户端本地数据库模块,能够把业务数据写入 SQLite 数据库文件,并通过 SQL 查询把数据取出来,最终在 Unity 的 UGUI 界面上以柱状图、折线图等可视化形式呈现。说白了就是一套“本地数据采集 → 入库 → 查询统计 → 图表展示”的完整链路。
1.1 为什么 Unity 项目需要 SQLite
很多刚接触 Unity 的人都会有疑问:Unity 不是自带 PlayerPrefs 吗?实在不行存 JSON 文件也可以,为什么非要引一个数据库进来?我实际用下来的感受是这样:
- PlayerPrefs 本质是一个键值对存储,适合存音量设置、上次登录时间这种零散数据。一旦遇到“玩家的 1000 条战斗记录,每条记录有 10 个字段,还要按时间区间查总和、平均值”这种需求,PlayerPrefs 基本就废了。
- JSON 文件方案能存结构化数据,但所有数据都得一次性加载进内存,数据量大了之后,查找、筛选、排序全得自己写算法,效率低还容易出 bug。
- SQLite 是嵌入式关系型数据库,服务端和客户端通用程度极高,支持标准的 SQL 语法,数据量几万条以内查询都是毫秒级,而且整个数据库就是一个独立文件,备份、迁移、清除都非常方便。
很多人对数据库有心理门槛,觉得那是后端工程师才需要碰的东西。但实际上 SQLite 不需要安装服务端,没有独立的进程,它是以库文件的形式嵌入到程序里的,Unity 项目里就是放几个 DLL 和原生库,然后通过 C# 调用。理解成“一个可以直接用 SQL 读写的结构化和本地文件”就行,跟“读写 JSON”相比只是换了个操作方式,但能力完全不在一个量级。
1.2 整体架构与模块划分
这个项目我分了三个层次来处理,避免把所有代码堆在一个类里,后续扩展和维护也省心:
- 数据持久化层:负责数据库文件的创建、连接管理、表结构初始化,对外提供 Insert、Query、Update、Delete 等基础方法。
- 业务逻辑层:负责把从数据库查出来的裸数据转换成 C# 对象,比如战斗记录 BattleRecord、统计数据 StatItem,这一层只跟集合和对象打交道。
- 可视化表现层:负责把业务层整理好的数据渲染成 UI 图表,包含柱状图、折线图组件,以及一个数据看板的布局。
这样的分层方式是标准的“数据 → 逻辑 → 展示”三段式。这样做最大的好处是,后续如果想把本地 SQLite 换掉直接对接网络接口,只需要替换第一层的实现,业务逻辑和图表代码都不用动。哪怕只做一个小项目,我也建议保持这个结构,因为你在开发过程中一定会频繁调整数据库字段和 UI 样式,分层能让你改动局部而不牵连全局。
2. 环境准备与工具链选型
动手之前先把环境搭对。这一节我用文字列出我自己的工程配置和工具,并且把每个选择的理由说明白。特别是插件选型,这一步要是选错了,后面会出现一堆莫名其妙的报错。
2.1 Unity 版本与脚本运行时配置
Unity 版本我使用的是 2021.3 LTS,这是一个长期支持版本,稳定性和插件兼容性都很好。如果你用的是 2019 或 2020,过程也完全一样,但建议至少保持在 2019.4 LTS 以上。
打开 Unity 后,先到 Project Settings → Player → Other Settings 里确认两处关键配置:
- Scripting Backend 选择 Mono。如果选择 IL2CPP 或者需要支持某些 Android 平台时,SQLite 的原生库需要额外的处理,后面我会专门讲。
- API Compatibility Level 选择 .NET Standard 2.0 或者 .NET Framework。重点说明一下,如果选了 .NET Standard 2.0,可能有些老版本 SQLite 插件会报“找不到 System.Data.dll”之类的错误,这时候可以切到 .NET Framework,或者干脆用依赖更少的 sqlite-net。
我在第一个测试项目里就踩过这个坑:插件已经引进来,代码编译也通过,一运行就报 FileNotFoundException,查了很久才发现是 API Compatibility Level 的问题。所以开局先把这项配置确定好,能省掉大量排查时间。
2.2 SQLite 插件选型对比
Unity 里面用 SQLite,主流的方案有下面几种,我整理成表格方便大家对照:
| 方案 | 底层实现 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Mono.Data.Sqlite | 通过 C# 调用引擎内置 SQLite | 老牌方案,资料多,Window 平台稳 | 更新慢,不同 Unity 版本兼容性有差异 | 本地工具、快速原型 |
| sqlite-net | 纯 C# + P/Invoke 封装 | 轻量、安装简单,支持 ORM | SQL 能力受限,复杂查询不如原生 SQL | 存档系统、中小型数据 |
| SQLite4Unity | 在 sqlite-net 基础上整合原生库 | 支持 Android/iOS/Windows,社区常用 | 需要自己处理原生库分发 | 发布移动端游戏 |
| 官方原生 sqlite3 | 直接引入官方源码或预编译库 | 功能最完整,可定制 | 工作量大,桥接代码要自己写 | 对 SQL 功能有极高要求 |
我个人最终选了 SQLite4Unity 这个方案。原因很简单:它既有 sqlite-net 的 ORM 便利性,又帮你把 Android、iOS、Windows 各个平台的 sqlite3 原生库都打好了包,引入工程后直接就能跑。不过要注意,sqlite4unity 仓库版本网上挺多,尽量选更新活跃、star 数量高的分支,避免拿到一个几年没更新的残缺版。
如果你只是在 Windows 编辑器里做工具类开发,不想碰移动端,用 Mono.Data.Sqlite 也完全够用。但如果是要发布到 Android 或 iOS 真机,强烈建议直接用 SQLite4Unity,或者深入理解底层后自己整合原生库,否则数据库打不开、读不了文件的情况会非常频繁。
2.3 外部数据库管理工具
数据可视化不只是把数据画在 Unity 里,开发过程中你还需要直接查看数据库文件的内容。这里推荐三款外部工具,都是我实际用过的:
- DB Browser for SQLite:免费开源,最推荐,查看表结构、执行 SQL、导出 CSV 都很方便,日常调试够用了。
- SQLiteStudio:同样是免费工具,界面比 DB Browser 稍微现代一点,支持数据导入导出和简单图表。
- DBeaver:全平台数据库管理工具,支持的数据库类型非常多,如果你同时在搞 Web 后端或数据分析工作,装这一个就够了。
这些工具的作用是让你能直接打开 App 生成的 .db 文件,用 SQL 语句验证数据是否正确。我通常的工作流是:Unity 里跑一次写入操作,然后把数据库文件拉出来,用 DB Browser 打开,执行几条查询语句,确认数据结构和内容符合预期,再回 Unity 里做可视化。
3. 核心实现:Unity 集成 SQLite 从零搭建
这一节是整篇文章的重头戏。我会按照“导入插件 → 封装数据库访问类 → 建表与增删改查 → 处理数据库文件路径”的顺序,把每一步操作讲透。
3.1 插件导入与工程结构
我这里以 SQLite4Unity 为例说下目录结构。下载插件后,把核心文件放到 Assets/Plugins/SQLite 目录下:
- sqlite3.dll(Windows 版本)
- libsqlite3.so(Android 版本)
- libsqlite3.dylib(iOS/macOS 版本)
- SQLite.cs(C# 层面的操作封装,也就是 sqlite-net 的源码)
放到 Plugins 目录下是 Unity 的约定,这个目录里的 DLL 和原生库会被自动识别并按平台打包。每个平台的子文件,Unity 会根据文件扩展名自动分发到对应平台,不需要你写额外的构建脚本。但要注意一点:iOS 平台原生的库会被编译进 Xcode 工程,必须确认它包含 arm64 架构;Android 平台要看 .so 的 CPU 架构(arm64-v8a 还是 armeabi-v7a),如果你的安卓设备比较新,建议只保留 arm64-v8a 以减小包体,但老的 32 位机器可能就跑不了。
导入完成后,在 Unity 里先做个最简单的连接测试:写一段代码尝试打开一个内存数据库,执行 SELECT 1,如果返回值正常,说明插件和底层库都通了。这一步能尽早暴露 DLL 版本问题。
3.2 数据库连接管理类封装
我不建议每个业务类都直接去 new SQLiteConnection,那样连接对象满天飞,资源管理迟早出问题。实际项目中我封装了一个数据库服务类 DBService,用单例来持有唯一的数据库连接。
public class DBService { private static DBService _instance; public static DBService Instance => _instance ?? (_instance = new DBService()); private SQLiteConnection _connection; private string _dbPath; public void Init(string dbName) { _dbPath = Path.Combine(Application.persistentDataPath, dbName); _connection = new SQLiteConnection(_dbPath); _connection.CreateTable<BattleRecord>(); // 其他建表操作... } public SQLiteConnection GetConnection() { return _connection; } }这里把数据库文件路径定在 Application.persistentDataPath,而不是 StreamingAssets。原因是 StreamingAssets 目录在 Android 上是只读的,你没法往里面写数据,而且从 Android 的 APK 包内读取 StreamingAssets 里的文件还需要用 UnityWebRequest 去走特殊协议,没法直接用 File 类。所以常规做法是:App 第一次启动时,把打包在 StreamingAssets 里的数据库模板(如果存在的话)复制到 persistentDataPath,之后所有读写操作都在这个持久化路径上执行。
3.3 建表、增删改查与自增 ID 获取
sqlite-net 这个库支持直接用类定义来建表,非常方便。比如我要存战斗记录,就定义一个 BattleRecord 类:
public class BattleRecord { [PrimaryKey, AutoIncrement] public int Id { get; set; } public string PlayerName { get; set; } public int Score { get; set; } public int KillCount { get; set; } public int DeathCount { get; set; } public string MapName { get; set; } public long Timestamp { get; set; } }第一次调用 CreateTable<BattleRecord>() 的时候,库里会自动创建一张名字为 BattleRecord 的表,字段名和类型会自动映射。对于不熟悉 ORM 的读者,我解释一下:PrimaryKey 是主键,AutoIncrement 是自增,也就是每条记录插入后会自动分配一个唯一数字 ID,这个 ID 就是我们在数据可视化时用来定位单条记录的依据。
插入数据并拿回自增 ID,这块就是热词里提到的“sqlite insert into 后获取自动序号”。sqlite-net 的做法是:
var record = new BattleRecord { PlayerName = "Player001", Score = 1200, KillCount = 5, DeathCount = 2, MapName = "Map01", Timestamp = DateTimeOffset.Now.ToUnixTimeSeconds() }; _connection.Insert(record); int newId = record.Id; // 插入之后,sqlite-net 会自动把自增Id写回这个对象如果你用的是原生 ADO.NET 的写法,那就要在 Insert 之后执行一句 SELECT last_insert_rowid():
cmd.CommandText = "SELECT last_insert_rowid()"; object result = cmd.ExecuteScalar(); int newId = Convert.ToInt32(result);这两种方式都能拿到自增 ID。我建议在项目里只统一使用其中一种,不要混着用。刚入门的时候混着用,很容易出现“刚才插入的明明是 A,拿到的 ID 却是 B”这种诡异问题。原因是 last_insert_rowid() 只对当前数据库连接内的上一次 INSERT 有效,如果中间穿插了其他连接的操作,结果就会错乱。
查询操作是可视化模块的基础。比如我要统计每张地图的玩家平均分和总击杀数,SQL 可以直接写:
var rows = _connection.Query<StatItem>( "SELECT MapName, COUNT(*) as PlayerCount, AVG(Score) as AvgScore, SUM(KillCount) as TotalKills " + "FROM BattleRecord GROUP BY MapName");对应的 StatItem 类:
public class StatItem { public string MapName { get; set; } public int PlayerCount { get; set; } public float AvgScore { get; set; } public int TotalKills { get; set; } }这里 SQL 语句里的 AVG、SUM、GROUP BY 都是数据库基本功,不用全部记住,但常用的这几个聚合函数值得花十分钟过一遍。因为数据可视化的本质就是“把原始数据按维度聚合后,用图形方式展示”。没有 GROUP BY,你只能画出一堆散点;有了 GROUP BY,你才能画柱状图(按分类汇总)和折线图(按时间序列汇总)。
更新和删除相对简单:
_connection.Execute("UPDATE BattleRecord SET Score = ? WHERE Id = ?", newScore, id); _connection.Execute("DELETE FROM BattleRecord WHERE Id = ?", id);注意 sqlite-net 的参数占位符用的是问号,不要把 MySQL 的 @ 参数格式套进来。
关于事务,我再多说一句。如果你需要一次性插入几千条数据,逐条 Insert 会非常慢,因为每条插入默认都开启一个隐式事务,磁盘 IO 开销很大。正确做法是用事务包住批量操作:
_connection.BeginTransaction(); try { foreach (var record in records) { _connection.Insert(record); } _connection.Commit(); } catch { _connection.Rollback(); throw; }用事务包住后,几千条数据一秒内就能插入完,性能差距非常明显。这也是新手最容易忽略的点:数据很小的时候看不出问题,数据量上来了才临时补事务,不如一开始就留好封装。
3.4 数据库文件路径与跨平台适配
前面说过,数据库文件本体建议放 persistentDataPath。但这个路径在不同平台位置不同,而且它是应用沙盒路径,用户在设备上是找不到的,调试的时候要么通过 Debug.Log 打印路径,要么用工具把数据库文件导出来。
如果你的项目需要在首次启动时预制一些基础数据,比如初始配置表、地图列表,那就把 ans 填充好的 sqlite 数据库文件放在 Assets/StreamingAssets 下,然后首次启动时判断持久化路径有没有这个文件,没有才复制过去:
string sourcePath = Path.Combine(Application.streamingAssetsPath, "game.db"); string targetPath = Path.Combine(Application.persistentDataPath, "game.db"); if (!File.Exists(targetPath)) { // Android 平台要用 UnityWebRequest 读取 StreamingAssets #if UNITY_ANDROID UnityWebRequest request = UnityWebRequest.Get(sourcePath); request.SendWebRequest(); while (!request.isDone) { } File.WriteAllBytes(targetPath, request.downloadHandler.data); #else File.Copy(sourcePath, targetPath); #endif }这里有一个非常经典的坑:Android 上 Application.streamingAssetsPath 指向的是 APK 包内的 jar 路径,用 File.Copy 直接读会报 DirectoryNotFoundException 或 FileNotFoundException。必须用 UnityWebRequest 拿到 Byte 数组后再写文件。iOS 上没有这个问题,但 iOS 的持久化路径受 iCloud 备份策略影响,如果数据库里有用户隐私数据,需要在 Infoplist 里设置开启或关闭备份属性,不过大多数游戏项目不涉及,先了解即可。
4. 数据可视化:从查询结果到图表
数据库集成好了,数据也查出来了,接下来就是把数据变成图表。Unity 里没有现成的图表控件,这和 Web 前端里面有 ECharts、D3.js 不一样,一切图表都得自己画或者引第三方库。
4.1 可视化方案选型
我实际对比过几种方案:
- 自绘 UGUI 图表:用 Image、RectTransform 来拼柱状图,用 LineRenderer 或 RawImage 来画折线图。适合数据量不大、图表样式要求定制化高的场景。优点是完全可控,缺点是一切要自己造轮子。
- 开源图表库 XCharts:基于 UGUI 的开源图表库,GitHub 上有,柱状图、折线图、饼图都有现成组件,配置项也很丰富。如果你只想快速出效果,直接导入 XCharts 是最省事的。
- 第三方插件(如 Graph Maker):Asset Store 上有一些商业图表插件,功能全面但对滨版本依赖强,升级 Unity 版本后可能失效。
- Web 可视化方案:把数据提交到本地 Web 页面,用 ECharts 渲染,Unity 内嵌 WebView 展示。技术路线复杂,一般不推荐在纯本地项目里用。
我这次没有引入外部图表库,因为需要展示的图表只有柱状图和折线图两种,自绘成本不高,而且能更好地控制颜色、坐标轴和交互。如果你有更复杂的图表需求(比如雷达图、热力图),或者时间紧迫,建议直接用 XCharts。
4.2 从数据库查询结果到内存模型
拉取数据后,先转成 UI 能直接消费的内存集合。比如我做一个“每张地图的平均分柱状图”,就需要得到一组“地图名 + 平均分”的数据:
public class ChartDataItem { public string Label { get; set; } public float Value { get; set; } } var chartDataList = _database.Query<ChartDataItem>( "SELECT MapName AS Label, AVG(Score) AS Value FROM BattleRecord GROUP BY MapName");把 SQL 的查询列用 AS 重命名成和类属性一致的名字,sqlite-net 才能正确映射。这个环节需要注意的是:SQL 返回的字段名如果和类属性名不一致,映射会失败,结果集是空的或者全是 0。我自己就踩过一次,因为把别名写错了,调试时查了半小时。
4.3 柱状图与折线图的 UGUI 实现思路
柱状图的原理其实很简单:每个柱子是一个 UGUI Image,通过控制它的 height 和 y 轴位置来表现数值大小。画一个图表容器,容器底部是 x 轴,每个 Image 的锚点设置在底部,运行时动态设置 sizeDelta:
float maxValue = 0f; foreach (var item in chartDataList) { if (item.Value > maxValue) maxValue = item.Value; } float barWidth = 80f; float chartHeight = 400f; for (int i = 0; i < chartDataList.Count; i++) { var bar = Instantiate(barPrefab, chartRoot); RectTransform barRect = bar.GetComponent<RectTransform>(); float normalizedValue = chartDataList[i].Value / maxValue; float barVisualHeight = chartHeight * normalizedValue; barRect.sizeDelta = new Vector2(barWidth, barVisualHeight); barRect.anchoredPosition = new Vector2(i * (barWidth + gap), barVisualHeight / 2f); }这里关键的细节是把柱子的 pivot 设在底部(0, 0),这样才能让柱子从底部向上生长。如果 pivot 是默认的 (0.5, 0.5),那么柱子会以中心点为基准向两边扩展,看起来就是从中间长出来的,非常怪。设置锚点这块对新手来说容易踩坑,建议画图前先把 UGUI 的 anchor、pivot、anchoredPosition 这三个概念过一遍。
折线图我选择用 LineRenderer 来实现。把数据点转成世界坐标,然后把相邻点连起来:
LineRenderer lineRenderer = lineObject.GetComponent<LineRenderer>(); lineRenderer.positionCount = chartDataList.Count; for (int i = 0; i < chartDataList.Count; i++) { float normalizedValue = chartDataList[i].Value / maxValue; float x = i * (barWidth + gap); float y = normalizedValue * chartHeight; lineRenderer.SetPosition(i, new Vector3(x, y, 0f)); }LineRenderer 用的坐标是 Unity 世界坐标,所以需要先把它挂在一个合适的 GameObject 下,确保坐标和柱状图的坐标能对上坐标原点和缩放。线条粗细和材质球要提前设置好,否则画出来很模糊或者看不见。
如果数据点很多,折线图最好开启抗锯齿,或者用更高分辨率纹理。性能方面,几百个点的折线图完全无压力,但如果上万个点,我建议先做降采样,只绘制每 N 个点中的一个,否则 UI 的 Grid 布局和刷新会变卡。
4.4 可视化看板布局与交互细节
图表组件做好后,我在一个独立的 Scene 里搭了整个数据看板。看板布局我用了 Canvas 下嵌套若干 Panel 的方式:上半部分是标题区和大数字摘要(比如总战斗场次、总击杀数、平均分数),下半部分左侧是柱状图,右侧是折线图。底部分页按钮可以切换维度(按地图、按时间、按玩家)。
摘要数字这部分可以从 SQL 里直接查:
var summary = _connection.ExecuteScalar<long>("SELECT COUNT(*) FROM BattleRecord");大数字文本的更新放在数据加载完成之后,不要放在 Update 里逐帧刷新,否则性能会被浪费。另外,图表的数据刷新我加了一个“刷新”按钮,点击后重新从数据库查询并重建图表。这是为了防止 UI 图表和数据库内容不同步。
交互层面,我给柱状图的每个柱子加了 Button 组件,点击后弹出详情面板,显示该柱子对应维度的明细数据。这种下钻交互是最基础的数据可视化操作,却能让看板一下子专业起来,毕竟“能看”和“能查”是两个层次的体验。
5. 常见问题与排查技巧实录
这一章我集中整理一下集成过程中遇到的典型问题,按“现象 → 原因 → 解决”的格式整理成速查表,再补充一些独家经验。
5.1 DLL 加载失败与 .NET 版本冲突
现象一:编译报错 CS0246,找不到 Mono.Data.Sqlite。原因:插件没有正确放在 Assets/Plugins 目录下,或者 .dll 文件没有勾选正确的平台兼容选项。解决:在 Project 面板里选中插件文件,在 Inspector 的 Select Platforms 中勾选需要的平台(最常见的是勾上 Any Platform 或 Standalone + Android + iOS)。
现象二:运行时报 DllNotFoundException: sqlite3。原因:sqlite3 原生库没有随构建被打包,或者 Unity 在运行时没能在正确的目录下找到对应的动态库。Windows 平台的 sqlite3.dll 要放在 Plugins 根目录;Android 的 .so 要放在 Plugins/Android 目录下对应 ABI 文件夹里;iOS 的 .a 静态库也要放在 Plugins/iOS 目录。发布前务必开启 Development Build,在真机上跑一遍连接测试。
5.2 Android 上数据库文件找不到或无法写入
现象:PC 编辑器上一切正常,一部署到 Android 就报“no such table”或者“could not open database file”。原因:90% 的情况是路径问题。开发时用了 Application.dataPath,它指向 APK 包内部,在 Android 沙盒限制下你没法写文件。解决:统一用 Application.persistentDataPath,并在首次运行时从 StreamingAssets 复制数据文件。
另一个容易忽略的点:如果你的 App 已经跑过一次,数据库文件已经被创建,后续代码改了表结构,比如新增了字段,旧库不会自动加列。排查思路是用 DB Browser 打开旧库,手动对比表结构和代码里的类定义。如果差异很大,最简单的方法是删掉旧库重跑一次,但这会导致用户本地数据丢失,所以正式项目要做数据库版本迁移,简单做法是维护一个版本号表,启动时比对版本,执行对应的 ALTER TABLE 语句。
5.3 中文乱码与编码问题
Unity 的字符串默认是 UTF-8,SQLite 存储文本本身不强制编码,但如果你在 SQL 语句里硬编码中文,比如WHERE MapName = '地图01',代码文件和编译产物的编码如果没有统一,可能出现乱码。解决:所有 C# 脚本统一保存为 UTF-8 with BOM,SQL 语句里尽量用参数化查询,不要拼接字符串。
参数化写法:
_connection.Execute("UPDATE BattleRecord SET Score = ? WHERE PlayerName = ?", score, playerName);这不止是编码问题,还能防止 SQL 注入。虽然本地数据库的注入风险很低,但养成参数化的习惯总是好的。
5.4 性能优化:查询慢、UI 卡顿
数据量不大的本地数据库,查询慢基本不会是 SQLite 的问题,而是你每帧都在查。新手常见写法是在 Update 里执行查询然后赋值给 Text,这是性能杀手。正确做法是:数据变化时才查询,或者至少间隔几秒刷新一次,查询结果缓存到内存列表。
另一个性能隐患是把整个表查出来再在 C# 里做聚合。其实 SQL 的聚合能力非常强,能查 AVG、SUM、COUNT、GROUP BY、ORDER BY,就尽量在数据库层完成,不要每次都把几千条原始记录全部 Load 到内存里再手动循环计算。除非你在做需要精细控制场景的特殊分析,否则“能下推给 SQL 的就下推给 SQL”。
UI 卡顿还可能是图表对象过度创建导致的。每次刷新都 Instantiate 几百个 Image,再销毁,会产生大量 GC。解决办法是对象池:一开始按最大柱子数量生成 Image,刷新时只改 Image 的 height 和颜色,不重复创建销毁。我实测下来,用对象池之后图表刷新耗时从数百毫秒降到了几十毫秒,画面流畅度提升明显。
5.5 自增 ID 获取与并发写入的坑
自增 ID 的问题前面提到过,这里再补一个并发场景。Unity 的单线程主线程模型下,数据库操作基本都在主线程,一般不会遇到并发冲突。但如果你用了异步任务(Task.Run)或者多线程插件去写数据库,就要小心两个线程同时 Insert 时拿到的 last_insert_rowid 不是自己的数据。
解决思路:
- 尽量把数据库写操作集中到主线程调度,通过队列把子线程的写入任务串行化。
- 如果必须跨线程,每个线程持有独立的 SQLiteConnection,避免共享连接。SQLite 本身支持多连接读,但同一连接同时读写会出现 busy 错误。
还有个小技巧:如果表结构里有唯一约束,插入之前可以先 SELECT 一次判断是否存在,避免重复数据。可视化统计中最常见的问题就是“数据被重复插入,图表翻倍”,本质上是没有对业务 ID 做唯一性处理。
6. 扩展思路与个人心得
这套“Unity + SQLite + 可视化”的组合,成型之后能干的事情比最初想象的要多。比如我可以把战斗录像的关键帧数据存进数据库,然后结合 Timeline 做回放;也可以把玩家在关卡内的路径点数据存下来,利用 SQLite 的 JSON 扩展或者单独的表结构,在编辑器里直接画出热力路径图。如果你有 3D 数据可视化的需求,甚至可以把 SolidWorks 等建模软件导出的模型放进 Unity,再用数据库驱动模型的位置、旋转、颜色,这比单纯的 UI 图表更直观。
最后分享一个很实际的经验:数据库字段设计阶段,一定要预留 create_time 和 update_time 两个字段,别嫌浪费。我吃了不少没预留时间字段的亏,后面想按时间维度画折线图,结果发现原始表里根本没有可用的时间戳,只能改表结构、写迁移脚本,绕了一大圈。如果一开始就带上时间字段,后面所有的按时间聚合查询都是顺手的事。
从插件选型到路径适配,从建表插数到图表自绘,这套流程我跑通之后,后续再做同类项目基本就是复用封装好的模块,省下来的时间足够把更多精力放在数据分析和交互体验上。希望这篇实战记录能帮你顺利把 SQLite 集成进你的 Unity 项目,少踩几个我已经替你踩过的坑。