UNITY使用SQLITE作为本地配置数据方案
对于游戏项目来说,前端有多种本地配置数据存储管理的方案,包括但不限于json、xml、lua、sqlite、自定义blob等,本文主要讲述以sqlite作为本地配置数据方案。
0x00. 优劣势分析
使用传统lua、json等方案保存配置的方案,已经运用在了很多游戏中。这类方案其最大的问题在于,对于大型项目来说,数据表会非常大,比如一张有几万行记录的道具表。需要访问里面数据的时候,就必然需要把整张表都加载到内存中。对于游戏某个运行时刻而言,这部分内存有很多是不必要的。实际项目中来看,如果缺少一定的数据内存管理机制,很可能光数据表就要占掉50M+的内存。因此,引入SQLITE作为本地配置数据的存储管理方案,出发点主要也是从这方面的考量。SQLite管理本地配置数据的优势主要是以下两点:
- 节省内存,对于大量数据来说,仅需要加载自己想要数据记录,其它数据不用进内存
- 数据库管理数据,使用SQL方便组合各种条件来查询自己想要的结果
有得必有失,使用SQLITE同时会存在以下的劣势:
- 复杂性:需要开发人员对SQL比较熟悉才能写出较为高效的查询语句
- 关系型数据库,不像其它类型可以直接以数组、树型等结构保存数据
- 加载大块数据的综合效率不如lua方案
后面同时也将介绍一些方法来规避掉这些劣势。
0x01. sqlite+xlua部署
对于大部分手机游戏来说,为了热更新方便,在功能模块开发的时候通常使用LUA作为脚本语言,数据表也需要直接由功能模块读取使用, 如果把对sqlite本地数据库的连接放在C#层,那必然会导致大量的C#和LUA之间互传数据产生大量的拆箱装箱的GC,影响性能。因此,需要把sqlite数据库连接直接放到Lua层。为此,我们使用sqlite官方支持的lua库:lsqlite http:// lua.sqlite.org/index.cg i/index ,将其作为三方库,整合配置到xlua里。关于xlua三方库导入可参考xlua官方文档。
0x02. 访问接口封装
- 建立数据库连接
- 在整合完毕sqlite+lua后,在lua中可以直接
local sqlite3 = require("lsqlite")
local db = sqlite3.open("db的本地完整路径")
assert(db.isopen()) --断言检查是否已成功建立数据库连接
- 数据缓存:我们希望不是每条数据都需要经过SQL查询,特别是已经访问过的数据,因此需要有一定的本地数据缓存机制
- 预加载整张表。有时候某些表数据是需要常驻内存以加快访问速度的,因此需要提供预加载整张数据表的接口(规避掉上面提到的劣势3)
- 对示已经查询过的数据,将结果缓存到本地cache字典,以ID为其索引
- 查询接口:对于普通前端开发人员而言,我们希望他们能不用了解SQL,或者不用直接写SQL语句,也能无缝访问数据(规避掉上面提到的劣势1)
- FindByID(tableName, idValue, idKey):根据某个主键ID直接获取指定表对应ID的对应数据,返回单行结果
- FindAll(tableName, ...):以组合条件查询指定多行数据,返回一个结果数组
- FindBySQL(sql):直接执行原始的SQL查询语句,以供复杂的数据查询,返回一个结果数组
-
Count(tableName, ...): 以组合条件查询符合数据的条数。返回int
0x03. 数据热更新
实际项目中我们发现,一个原始data.db文件有30M,如果直接更新一个30M的文件,还是有点大。有时候我们只需要更新几张表,甚至几行记录。因此,有以下几种数据热更新优化方案:
- 对于数据表有大量变化的情况:整个更新DB文件,将data.db压缩后热更下来再解压。实测30M左右的DB文件,压缩成ZIP后仅5M左右。
- 对于小量数据表有更新的情况:以CSV/TSV更新单张表,数据库均支持批量插入操作,对于sqlite来说同样支持,可以使用以下方式来批量导入数据
local insert = string.format("INSERT INTO %s VALUES (%s)", tbName, table.concat(t, ","))
local stmt = db:prepare(insert)
for i=2,lines.Length-1 do --第一行是表头,数据从第二行开始
local cols = string.split(lines[i], "\t")
if #cols == #heads then
stmt:reset()
stmt:bind_values(table.unpack(cols))
stmt:step()
stmt:finalize()
db:exec("commit;")
-
对于仅有指定表的少量数据行发生变更的情况:以SQL语句更新指定记录,直接执行热更新下载下来的.sql文件即可
0x04. Tricks
- 如何存取复杂的数据结构:由于关系型数据库不支持数组、树型等数据,但实际上在游戏项目中又经常需要将复杂一点的数据结构保存起来,对于这种情况,我们使用直接往sqlite数据库里存放lua string,然后在数据访问端使用lua的接口loadstring,这样可以直接返回一个复杂的结构,包括但不限于数组、树型、字典表等结构,规避掉上面掉到的劣势2
- 对于某些逻辑上不止一个主键的数据表,合理建立索引可以加速查询速度。
- 如何确定某张数据表是否存在:使用lua的xpcall捕获select count(*) from XXXX来捕获异常,如果有异常发生,则数据表不存在。
0x05. Talking is Cheap, Show Me the Code
--- 数据库访问接口类
--- @class SqliteDBManager
--- @alias DBMgr
local sqlite3 = require("lsqlite")
local DBManager = class("DBManager")
function DBManager:ctor()
self.cache = {}
setmetatable(self.cache, {_mode = "v"})
self.emptyCache = {}
self.loadedTables = {}
if Constants.isEditor then
-- NOTE 如果没有plan的权限,可以临时使用streamingAssetsPath下的data.db,后面有权限了再使用plan
self:Init(string.format("%s/../%s", Constants.dataPath, CS.AppConfigs.Instance.editorSettings.dataPath))
self:Init(string.format("%s/%s", Constants.persistentDataPath, CS.AppConsts.DBFile))
---初始化数据库连接
--- @param dbFullPath 数据库db文件完整路径
function DBManager:Init(dbFullPath)
-- print("connect db: ", dbFullPath)
if self.db and self.db:isopen() then self.db:close() end
self.db = sqlite3.open(dbFullPath)
assert(self.db:isopen(), "failed to open db connection: "..dbFullPath)
---检查数据表热更新
function DBManager:CheckHotfix()
local sqlFileName = CS.FileUtils.GetSqlFileNames("*.sql") --- 加载SQL文件热更新单张表
if sqlFileName then
local count = sqlFileName.Length
if count > 0 then
local time = os.clock()
for i=0,count-1 do
local filePath = sqlFileName[i]
local sqlStr = CS.FileUtils.ReadAllText(filePath)
self.db:exec(sqlStr)
CS.FileUtils.DeleteFile(filePath)
Log("Load Hotfix Data cost time: "..tostring(os.clock()-time))
local tsvFileName = CS.FileUtils.GetSqlFileNames("*.tsv") --- 加载TSV文件热更新单张表
if tsvFileName then
local count = tsvFileName.Length
if count > 0 then
for i=0,count-1 do
local filePath = tsvFileName[i]
if self:LoadTSVData(filePath) then
CS.FileUtils.DeleteFile(filePath)
--- 加载TSV文件热更新单张表
function DBManager:LoadTSVData(tsvFile)
local tbName = CS.System.IO.Path.GetFileNameWithoutExtension(tsvFile)
self.db:exec(string.format("DROP TABLE IF EXISTS '%s';", tbName))
local lines = CS.System.IO.File.ReadAllLines(tsvFile)
if lines.Length > 2 then
local time = CS.UnityEngine.Time.realtimeSinceStartup
local heads = string.split(lines[0], "\t")
local datatype = string.split(lines[1], "\t")
if #heads == #datatype then
for i=1, #heads do
local dtype = "TEXT"
if datatype[i] == "int" then dtype = "INTEGER" end
heads[i] = string.format("[%s] %s",heads[i], dtype)
error(string.format("[%s] heads.length(%d) != datatype.length(%d)", tbName, #heads, #datatype))
return false
local ddl = string.format("CREATE TABLE %s (%s);", tbName, table.concat(heads, ","))
self.db:exec(ddl)
self.db:exec("begin;")
local t = {}
for i=1,#heads do
table.insert(t,"?")
local insert = string.format("INSERT INTO %s VALUES (%s)", tbName, table.concat(t, ","))
local stmt = self.db:prepare(insert)
if stmt then
for i=2,lines.Length-1 do
local cols = string.split(lines[i], "\t")
if #cols == #heads then
stmt:reset()
stmt:bind_values(table.unpack(cols))
stmt:step()
Log(string.format("[%s] will ignore line %s, (%d / %d)", tbName, lines[i], #cols, #heads))
stmt:finalize()
error(string.format("[%s] prepare statement failed.\n %s\n %s", tbName, ddl, insert))
return false
self.db:exec("commit;")
Log(str("Load '", tbName, "' Data cost time: "..tostring(CS.UnityEngine.Time.realtimeSinceStartup-time)))
return true
---加载整张表至缓存中并返回表数据
---@param tableName string
---@param idKey ID字段名称(默认为id)
---@return table
function DBManager:LoadTable(tableName, idKey)
if self.loadedTables[tableName] and self.cache[tableName] then
return self.cache[tableName]
self.cache[tableName] = {}
if not idKey then idKey = "id" end
xpcall(function()
for row in self.db:nrows("SELECT * FROM "..tableName) do
if row[idKey] then
self.cache[tableName][row[idKey]] = row
if not self.cache[tableName] then
self.cache[tableName] = {}
table.insert(self.cache[tableName], row)
end, function(err)
LogF(err)
self.loadedTables[tableName] = true
return self.cache[tableName]
---检查数据表是否存在
--- @return true or false
function DBManager:CheckTableExist(tableName)
local ret = true
xpcall(function()
self.db:nrows('SELECT count(*) FROM '..tableName)
ret = true
end, function(err)
ret = false
return ret
---查找指定行数据,该接口将缓存数据,以备下次调用时快速访问
---@param tableName 表名
---@param value 值
---@param idKey ID名(默认为id)
function DBManager:FindByID(tableName, value, idKey)
if not value then
Log("查找失败,请传入value")
return
if not idKey then idKey = "id" end
if self.cache[tableName] and self.cache[tableName][value] then
return self.cache[tableName][value]
elseif self.emptyCache[tableName] and self.emptyCache[tableName][value] then
return
if not self.cache[tableName] then
self.cache[tableName] = {}
local sql = 'SELECT * FROM '..tableName..' where '..idKey..'="'..value..'"'
for row in self.db:nrows(sql) do
if value == row[idKey] then
self.cache[tableName][row[idKey]] = row
return row
if not self.emptyCache[tableName] then
self.emptyCache[tableName] = {}
self.emptyCache[tableName][value] = true
--- 查找指定表的所有数据
--- @param tableName 表名
--- @param ... 可变长参数
function DBManager:FindAll(tableName, ...)
local conditions = {...}
local results = {}
local sql = string.format('SELECT * FROM %s', tableName)
if #conditions > 0 then
sql = sql.." where "
for i=1,#conditions, 2 do
sql = sql..conditions[i].."='"..conditions[i+1] .."' and "
sql = string.sub(sql, 1, -6)
xpcall(function ()
for row in self.db:nrows(sql) do
table.insert(results, row)
end,function (err)
error(tableName.." 表找不到,或sql执行错误 " .. sql.."\n"..debug.traceback())
return results
--- 根据SQL语句查找所有数据
function DBManager:FindAllBySql(sql)
local results = {}
for row in self.db:nrows(sql) do
table.insert(results, row)
return results
--- 查找记录条数
function DBManager:Count(tableName, ...)
local conditions = {...}
local results = 0
local sql = string.format('SELECT count(*) as T FROM %s', tableName)
if #conditions > 0 then
sql = sql.." where "
for i=1,#conditions, 2 do
sql = sql..conditions[i].."='"..conditions[i+1] .."' and "
sql = string.sub(sql, 1, -6)
xpcall(function ()
for row in self.db:nrows(sql) do
results = row.T
end,function (err)
error(tableName.." 表找不到,或sql执行错误 " .. sql)
return results