UNITY使用SQLITE作为本地配置数据方案

对于游戏项目来说,前端有多种本地配置数据存储管理的方案,包括但不限于json、xml、lua、sqlite、自定义blob等,本文主要讲述以sqlite作为本地配置数据方案。


0x00. 优劣势分析

使用传统lua、json等方案保存配置的方案,已经运用在了很多游戏中。这类方案其最大的问题在于,对于大型项目来说,数据表会非常大,比如一张有几万行记录的道具表。需要访问里面数据的时候,就必然需要把整张表都加载到内存中。对于游戏某个运行时刻而言,这部分内存有很多是不必要的。实际项目中来看,如果缺少一定的数据内存管理机制,很可能光数据表就要占掉50M+的内存。因此,引入SQLITE作为本地配置数据的存储管理方案,出发点主要也是从这方面的考量。SQLite管理本地配置数据的优势主要是以下两点:

  1. 节省内存,对于大量数据来说,仅需要加载自己想要数据记录,其它数据不用进内存
  2. 数据库管理数据,使用SQL方便组合各种条件来查询自己想要的结果

有得必有失,使用SQLITE同时会存在以下的劣势:

  1. 复杂性:需要开发人员对SQL比较熟悉才能写出较为高效的查询语句
  2. 关系型数据库,不像其它类型可以直接以数组、树型等结构保存数据
  3. 加载大块数据的综合效率不如lua方案

后面同时也将介绍一些方法来规避掉这些劣势。


0x01. sqlite+xlua部署

对于大部分手机游戏来说,为了热更新方便,在功能模块开发的时候通常使用LUA作为脚本语言,数据表也需要直接由功能模块读取使用, 如果把对sqlite本地数据库的连接放在C#层,那必然会导致大量的C#和LUA之间互传数据产生大量的拆箱装箱的GC,影响性能。因此,需要把sqlite数据库连接直接放到Lua层。为此,我们使用sqlite官方支持的lua库:lsqlite lua.sqlite.org/index.cg ,将其作为三方库,整合配置到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