做iOS开发这么多年,数据持久化永远是绕不开的一环。不管是缓存用户信息、存储离线数据还是维护本地业务记录,SQL都是最基础也最核心的能力之一。这篇文章我把iOS开发里和SQL、SQLite相关的内容完整梳理一遍,从底层原理到实际写码,从基础语法到性能优化,再到我踩过的坑和排查思路,一次性讲透。
内容适合刚接触iOS数据库的新手,也适合已经写了一段时间、想系统性补强数据库这块的老手。看完之后你至少能独立完成一个基于SQLite的本地数据层设计,并且知道哪些场景该用数据库、哪些场景其实不该用。
1. iOS本地数据库选型,为什么SQLite是默认答案
1.1 iOS本地持久化的主流方案对比
很多初学者刚接触iOS数据存储时,第一个想到的往往是UserDefaults,因为简单嘛,设置一个key存一个value,几行代码搞定。再加上后来有了SwiftData、CoreData这些苹果官方框架,很多人会问:是不是直接用官方推荐的就够了?我的答案是:看场景。
如果你要存的只是几十个键值对、用户偏好设置、登录token这类轻量数据,UserDefaults完全够用,非要上SQLite反而是过度设计。但如果你要做的功能是聊天记录列表、商品历史浏览记录、打车订单流水、运动轨迹上报队列这类结构化、需要查询和筛选的数据,UserDefaults就会迅速变成噩梦——读取全量数据、手动做过滤、内存暴涨、写频繁导致性能差,这些坑我全踩过。
CoreData则是苹果官方推出的对象关系映射框架,底层可以用SQLite做持久化存储,但它更像一个框架而不是单纯的数据库工具。它有自己的数据模型文件、上下文管理、请求模板,学习成本不低,而且因为封装层级多,排查问题的时候经常要穿透很多层才能定位到真正的SQL语句。在团队协作里,如果大家没有统一规范,CoreData写出来的查询代码很容易风格各异,维护起来头疼。
SQLite是文件型数据库,整个数据库就是一个独立的文件,iOS应用可以直接把库文件放在沙盒里,不需要单独的数据库服务器,也不需要网络连接。它支持的SQL语法覆盖了绝大多数日常开发需求,性能、稳定性、跨平台兼容性都非常好。这也是为什么App Store里大量应用的数据层核心依然是SQLite,包括微信、支付宝这类体量的应用。
1.2 沙盒机制决定了SQLite文件存放在哪里
iOS的每个应用都有自己独立的沙盒目录,应用只能访问自己沙盒内的文件,这就决定了SQLite数据库文件必须放在沙盒路径下面。通常我会把数据库放在Library/Caches目录或者Documents目录,两者的区别在于:
- Documents目录:会被系统自动备份到iCloud,适合放用户真正产生的、不可重新生成的业务数据,比如用户创建的笔记正文。
- Library/Caches目录:不会备份,适合放可以重新下载或者重建的缓存数据,比如图片缓存索引、历史搜索记录。
如果你的数据库混入了敏感信息,比如用户的手机号、设备标识,我倾向于放在Library/Application Support目录下,并且开启文件保护属性。核心原则是:涉及隐私的库文件,一定要设置NSFileProtectionComplete,避免备份泄露风险。
1.3 SQLite在iOS里的三种用法
iOS项目里接SQLite,实际开发中一般有三种路径:
- 直接用系统自带的sqlite3 C语言接口。好处是零依赖、最底层、完全可控;坏处是API确实很原始,写起来繁琐,每次查询都要手动绑定参数、释放句柄。
- 用FMDB这样的轻量封装库。FMDB在sqlite3的基础上封装了Objective-C对象,写起来舒服很多,而且提供了线程安全的FMDatabaseQueue,是很多老项目里最常见的选型。
- 用SQLite.swift这类Swift原生库。类型安全、链式语法,适合纯Swift项目,但它的高级API背后会把你的操作翻译成SQL,有时反而不如直接写SQL直观。
我的建议是:如果你做的是快速原型,选FMDB或者SQLite.swift都可以;如果你做的是长期维护、需要稳定性和可控性的项目,直接掌握原生sqlite3 API,再包一层自己的数据访问层,反而最踏实。因为不管封装多好,底层都是SQL,你越懂底层,就越不会被框架卡脖子。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SQL基础语法拆解,先搞清楚增删改查
2.1 建表语句是地基,字段设计决定成败
SQL语言里最重要的不是花哨的查询技巧,而是建表。表结构设计不好,后面所有查询都会很难受。iOS本地数据库建表,最常用的是CREATE TABLE语句:
sql复制CREATE TABLE IF NOT EXISTS user_info (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL UNIQUE,
nickname TEXT,
avatar_url TEXT,
age INTEGER DEFAULT 0,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
);
这里面有几个点值得展开说说。
第一,主键设计。我见过很多表喜欢用自增整型做主键,本身没问题,但要注意自增主键在数据删除、迁移时可能会产生主键冲突或者空洞,所以如果业务上有业务主键(比如用户ID、订单编号),更好的是用业务主键做UNIQUE约束,再配一个自增id作为内部引用。
第二,字段类型。SQLite属于动态类型系统,它不严格校验你插入的数据类型,一个声明为INTEGER的列你完全可以塞入文本。表面看很灵活,实际上埋了雷。所以建表之后,最好在业务层就做好类型校验,SQLite层面也要给关键字段NOT NULL约束兜底。
第三,时间字段。我习惯用REAL类型存Unix时间戳,一方面是排序方便,另一方面是时区问题可规避。用字符串存时间做比较容易因为格式不统一翻车,用时间戳就干净利落。
第四,索引与约束的取舍。约束写多了会影响写入性能,写少了又会造成脏数据。常规策略是:单表数据量在万级以下,不用急着做太多索引;超过十万级,必须给查询频繁的WHERE条件字段加索引,这个下面第三章节会细讲。
2.2 核心CRUD语句,一篇全讲透
SQL上手最快的方式就是直接记增删改查四类语句。我按日常使用频率排个序:
查询(SELECT)
sql复制-- 基础查询
SELECT user_id, nickname FROM user_info WHERE age > 18;
-- 排序与分页
SELECT * FROM user_info ORDER BY created_at DESC LIMIT 20 OFFSET 0;
-- 去重
SELECT DISTINCT city FROM user_info;
-- 聚合统计
SELECT COUNT(*) FROM user_info WHERE age >= 18;
查询语句是SQL里变化最多的,也是慢SQL问题的高发区。这里我的心得是:能明确列名就不要用SELECT *,尤其是表字段变多的时候,星号查询会把不必要的字段全部拉出来,浪费IO和内存。排序和分页组合使用时,一定要确保ORDER BY的字段上有索引,否则数据量大起来会非常慢。
插入(INSERT)
sql复制INSERT INTO user_info (user_id, nickname, age) VALUES ('u001', '张三', 25);
批量插入时,不要一条条INSERT,要放在事务里批量执行。iOS本地写入其实比很多人想象中慢,逐条插入1万条记录可能要好几秒,如果包在事务里,同样的数据几十毫秒就能完成,这个差距非常大。
更新(UPDATE)
sql复制UPDATE user_info SET nickname = '李四', updated_at = 1623456789 WHERE user_id = 'u001';
更新操作最容易犯的错是忘记加WHERE条件,导致全表数据被改。我在本地调试的时候干过这种事,一条UPDATE直接把测试数据全部改了,从那以后我在写UPDATE语句时都是先把WHERE条件写好,再回去补SET部分。另外批量更新不同记录时,同样建议配合事务使用。
删除(DELETE)
sql复制DELETE FROM user_info WHERE user_id = 'u001';
删除操作同样要谨慎。业务上如果是用户主动删数据,我建议直接用DELETE;但如果只是给数据打标记,更稳妥的形态是增加一个deleted字段做软删除,后续还能恢复和排查问题。
2.3 多条件组合查询与LIKE模糊搜索
本地数据库最常见的查询场景是多条件组合筛选,比如筛选某城市、某年龄段、某注册时间段的用户。SQL的WHERE子句可以用AND、OR组合条件:
sql复制SELECT * FROM user_info
WHERE city = '北京'
AND age BETWEEN 20 AND 35
AND created_at > 1609430400
ORDER BY created_at DESC;
这里有几个要点。BETWEEN是闭区间,包含两端的值,容易踩坑的是条件边界和预想不一致。LIKE模糊查询(例如WHERE nickname LIKE '%张%')在SQLite里只能走全表扫描,如果表数据量很大,性能会很差。遇到这种需求更要考虑在业务层用其它方案,比如用专用的搜索框架,或者把模糊查询限制在小数据集范围内。
2.4 JOIN联表查询的基本功
本地数据库跟服务器数据库一样,表与表之间经常有外键关系。比如一个订单表引用商品表,一个评论表引用用户表。这时就需要JOIN语句:
sql复制SELECT o.order_id, u.nickname, g.name
FROM orders o
LEFT JOIN user_info u ON o.user_id = u.user_id
LEFT JOIN goods g ON o.goods_id = g.goods_id
WHERE o.status = 'paid';
JOIN容易犯错的地方是关联条件写错,比如关联的是id而不是业务主键,或者用错了内连接和左连接导致结果行数不符合预期。经验之谈:写关联查询前先确认两个表之间的基数关系,是一对一、一对多还是多对多,基数理解错了,连表结果必然不对。
3. iOS接入SQLite实操:从底层到封装
3.1 原生sqlite3库的接入流程
在iOS工程里直接使用SQLite,不需要安装任何第三方库,系统自带了sqlite3库。第一步是导入头文件:
在Objective-C工程里,引入libsqlite3.tbd依赖,然后在代码里#import <sqlite3.h>。Swift工程需要在桥接文件中引入。
最基础的使用流程是:打开数据库、执行SQL、取出结果、关闭数据库。我给你写一个完整的Objective-C示例:
objectivec复制#import <sqlite3.h>
NSString *dbPath = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents/app.db"];
sqlite3 *db = NULL;
if (sqlite3_open(dbPath.UTF8String, &db) != SQLITE_OK) {
NSLog(@"打开数据库失败: %s", sqlite3_errmsg(db));
sqlite3_close(db);
return;
}
NSString *createSQL = @"CREATE TABLE IF NOT EXISTS user_info (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, nickname TEXT, age INTEGER)";
char *error = NULL;
if (sqlite3_exec(db, createSQL.UTF8String, NULL, NULL, &error) != SQLITE_OK) {
NSLog(@"建表失败: %s", error);
sqlite3_free(error);
sqlite3_close(db);
return;
}
sqlite3_stmt *stmt = NULL;
NSString *querySQL = @"SELECT user_id, nickname, age FROM user_info WHERE age > ?";
if (sqlite3_prepare_v2(db, querySQL.UTF8String, -1, &stmt, NULL) == SQLITE_OK) {
sqlite3_bind_int(stmt, 1, 18);
while (sqlite3_step(stmt) == SQLITE_ROW) {
const unsigned char *userId = sqlite3_column_text(stmt, 0);
const unsigned char *nickname = sqlite3_column_text(stmt, 1);
int age = sqlite3_column_int(stmt, 2);
NSLog(@"user=%@ nickname=%@ age=%d",
[NSString stringWithUTF8String:(const char *)userId],
[NSString stringWithUTF8String:(const char *)nickname],
age);
}
}
sqlite3_finalize(stmt);
sqlite3_close(db);
这段代码就是原生SQLite开发的最完整骨架。注意几个关键点:
- 占位符?的使用。永远不要用字符串拼接方式把用户输入拼进SQL里,这是SQL注入的根源。iOS本地数据库虽然没有网络安全问题,但如果你处理的数据来自服务器,恶意数据同样可能通过拼接SQL造成逻辑问题。
- sqlite3_stmt是预处理语句。prepare之后可以反复绑定参数执行,比每次拼接SQL再执行高效得多。
- 用完后必须sqlite3_finalize释放stmt,否则会有内存泄漏。sqlite3_close之前,所有stmt都要先finalize。
3.2 用FMDB封装,开发效率显著提升
直接写原生接口,代码量确实比较大,而且容易出现泄漏问题。老项目中我推荐使用FMDB做一层封装。FMDB把打开、执行、查询、事务这些操作都封装成了Objective-C的对象方法,读起来直观很多:
objectivec复制#import <FMDB.h>
NSString *dbPath = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents/app.db"];
FMDatabase *db = [FMDatabase databaseWithPath:dbPath];
if (![db open]) {
NSLog(@"打开失败");
return;
}
BOOL success = [db executeUpdate:@"CREATE TABLE IF NOT EXISTS user_info (user_id TEXT PRIMARY KEY, nickname TEXT, age INTEGER)"];
if (success) {
[db executeUpdate:@"INSERT OR REPLACE INTO user_info (user_id, nickname, age) VALUES (?, ?, ?)", @"u001", @"张三", @25];
}
FMResultSet *result = [db executeQuery:@"SELECT * FROM user_info WHERE age > ?", @18];
while ([result next]) {
NSString *nickname = [result stringForColumn:@"nickname"];
NSInteger age = [result intForColumn:@"age"];
NSLog(@"nickname=%@ age=%ld", nickname, age);
}
[result close];
[db close];
FMDB的好处在:它内部的executeUpdate和executeQuery会自动处理一些SQLite的字节编码问题,而且所有参数支持NSObject类型,不用手动处理C字符串和类型转换。但要注意,FMDatabase本身不是线程安全的,多线程环境下不要用一个实例并发访问,这是最容易翻车的地方。
3.3 FMDatabaseQueue,线程安全的标准答案
iOS应用里的数据库访问往往发生在多个线程:主线程读取展示、后台线程写入日志、推送回调更新数据。如果多个线程同时操作同一个FMDatabase实例,SQLite会因为数据库被锁而报database is locked错误,这是新手最常见的错误之一。
FMDB官方给出的方案是FMDatabaseQueue,它是一个串行队列,把数据库操作同步放到队里执行,从机制上杜绝了并发写入冲突:
objectivec复制FMDatabaseQueue *queue = [FMDatabaseQueue databaseQueueWithPath:dbPath];
[queue inDatabase:^(FMDatabase *db) {
[db executeUpdate:@"INSERT INTO user_info (user_id, nickname) VALUES (?, ?)", @"u002", @"王五"];
FMResultSet *result = [db executeQuery:@"SELECT * FROM user_info"];
while ([result next]) {
// 处理结果
}
[result close];
}];
FMDatabaseQueue的使用原则是:所有数据库操作都放到inDatabase或者inTransaction的block里面,不要在block外面持有FMDatabase做操作。另外inTransaction方法适合批量操作,它会自动开启和提交事务,出异常时自动回滚。
我在实际项目里经历过一次事故:当时团队里有人图方便,在后台线程直接用FMDatabase实例写数据,结果线上用户频繁反馈数据加载失败,查日志全是SQLITE_BUSY。后来全部改成FMDatabaseQueue之后问题就消失了。这个经验提醒我:线程安全不是可选优化,而是必须项。
3.4 Swift项目里的SQLite.swift选型
如果你从零开始写一个纯Swift项目,可以考虑SQLite.swift。它把基本CRUD封装成了类型安全的API,例如:
swift复制import SQLite
let db = try Connection(dbPath)
let users = Table("user_info")
let userId = Expression<String>("user_id")
let nickname = Expression<String>("nickname")
let age = Expression<Int>("age")
try db.run(users.create(temporary: false, ifNotExists: true) { t in
t.column(userId, primaryKey: true)
t.column(nickname)
t.column(age)
})
try db.run(users.insert(userId <- "u003", nickname <- "赵六", age <- 28))
for user in try db.prepare(users.filter(age > 18).order(age.desc)) {
print("nickname: \(user[nickname])")
}
SQLite.swift的表达式写法确实优雅,但也有一点需要注意:它的链式API背后生成的SQL并不总是最优的。遇到复杂查询时,我建议直接用它提供的db.prepare("SELECT ...")手写SQL,不要硬用表达式堆,反而更可控。
4. 进阶:事务、索引与慢SQL优化
4.1 事务批量写入,性能提升一个数量级
SQLite在没有显式开启事务的时候,每一条INSERT语句都会被当作一个独立事务提交,而每次提交都需要进行一次磁盘同步。磁盘同步是昂贵的操作,所以逐条插入大量数据会非常慢。
解决方案是显式使用事务。FMDB的inTransaction封装了完整的事务流程:
objectivec复制[queue inTransaction:^(FMDatabase *db, BOOL *rollback) {
for (NSDictionary *item in items) {
BOOL success = [db executeUpdate:@"INSERT INTO user_info (user_id, nickname) VALUES (?, ?)", item[@"id"], item[@"name"]];
if (!success) {
*rollback = YES;
break;
}
}
}];
如果使用普通的SQLite C接口,也可以用BEGIN、COMMIT、ROLLBACK手动控制事务:
sql复制BEGIN TRANSACTION;
INSERT INTO user_info (user_id, nickname) VALUES ('u100', '测试1');
INSERT INTO user_info (user_id, nickname) VALUES ('u101', '测试2');
COMMIT;
我实测过一个典型场景:向本地表插入2万条记录,逐条executeUpdate耗时约4.2秒,改用事务后耗时约120毫秒,性能提升超过了30倍。如果你的应用有本地缓存大批量数据的场景(比如首次启动从服务器拉取基础数据),事务是必不可少的优化手段。
4.2 索引设计,该加的时候就加
索引是数据库优化里见效最快的手段。对WHERE中频繁使用的字段建立索引,可以让查询从全表扫描变成索引查找,数据量越大改善越明显。
在SQLite里创建索引有两种方式。一种是在建表时直接创建,一种是用独立语句:
sql复制CREATE INDEX IF NOT EXISTS idx_user_city ON user_info(city);
CREATE INDEX IF NOT EXISTS idx_user_age ON user_info(age DESC);
索引的设计遵循几个朴素原则:
- 频繁查询的字段才加索引,不要每个字段都加。索引是读优化的利器,但会拖慢写入,因为每次写入都要同步维护索引。
- 复合索引要关注字段顺序。比如经常按city+age查询,可以建(city, age)联合索引。SQLite对联合索引最左前缀匹配有效,所以字段顺序要贴合查询条件。
- 低区分度字段不要单独建索引,比如gender字段只有男和女,区分度太低,索引效率不高,反而不如全表扫描。
在iOS本地库里,如果单表数据量经常在几千条以内,索引的作用几乎可以忽略。加入索引的时机应该是:你明显感到查询变慢,或者数据量稳定超过几万条。过早优化反而增加复杂度。
4.3 慢SQL的识别与优化思路
慢SQL不是服务端专属问题,客户端本地数据库同样存在。当我发现UI卡顿或者数据库操作耗时明显时,会先做一次SQL语句耗时统计。
在FMDB项目中,可以通过FMDatabase的logStats参数开启统计功能:
objectivec复制db.logsErrors = YES;
db.shouldCacheStatements = YES;
更通用的方式是在自定义的数据库工具类里给每个查询打点,记录执行耗时,筛选出超过100ms的语句做重点优化。常见的慢SQL原因基本集中在:
- 全表扫描:WHERE条件没有走索引,或者LIKE前置通配符。
- 返回过多字段:把不用的BLOB字段也查出来了。
- 排序无索引:ORDER BY字段没有索引覆盖。
- 频繁打开关闭数据库连接:没复用连接,每次都走完整初始化流程。
优化手段按照性价比排序:先加索引,再裁剪返回列,然后优化查询条件写法,最后考虑把复杂查询拆分成多次简单查询。很多时候把一个大JOIN拆成两次独立查询再在内存里合并,性能反而更好。
4.4 数据库文件损坏与备份恢复
SQLite是文件型数据库,文件损坏会直接导致应用数据无法读取。最坏的情况是用户升级App后数据库文件损坏,导致所有本地数据丢失。iOS设备在数据库写入过程中可能被系统杀掉进程、断电(越狱设备)、磁盘空间不足,这些异常都可能导致库文件损坏。
我的防护策略有三层:
- 第一层,写入时使用SQLite的WAL模式,可以降低写过程中断导致损坏的概率。开启方式:sqlite3_exec(db, "PRAGMA journal_mode=WAL;", ...)。
- 第二层,定期备份数据库文件。在应用启动时,把Documents下的db文件复制到Caches目录下备份一份。数据迁移或者数据库升级前,先备份旧的db文件。
- 第三层,启动时做快速健康检查。执行SELECT count(*) FROM sqlite_master,如果报错就说明文件可能已损坏,此时优先恢复备份文件。
恢复备份的逻辑不复杂,但真正有效。我做过一个图片缓存索引库,曾经因为一次磁盘空间写满导致数据库损坏,如果没有备份机制,用户几十个相册的本地索引就全没了。从那以后,我每次做本地数据层都会强制要求保留一份可恢复的备份。
5. iOS本地数据库开发常见问题排查
5.1 数据库打不开的经典原因
打开数据库失败是新手遇到最多的错误,核心表现在sqlite3_open返回非SQLITE_OK。原因大概有这么几类:
- 路径写错。沙盒目录的路径并不是固定不变的,每次启动UUID可能变化,所以不要硬编码路径,要通过NSSearchPathForDirectoriesInDomains或者NSHomeDirectory动态获取。
- 父目录不存在。数据库文件所在的目录必须先创建。如果Documents下还有个自定义子目录,要先用NSFileManager创建。
- 权限问题。沙盒目录通常是可读写的,但如果你把数据库文件放在App Bundle目录里,那这个目录是只读的,打开就会失败,读取的话建议先拷贝到Documents。
遇到这类问题,我习惯在打开数据库后立即打印错误信息:
objectivec复制if (sqlite3_open(dbPath.UTF8String, &db) != SQLITE_OK) {
NSLog(@"open failed: %@", [NSString stringWithUTF8String:sqlite3_errmsg(db)]);
}
5.2 database is locked错误的排查思路
SQLite报SQLITE_BUSY或者database is locked,表示一个连接在写操作时锁住了数据库,另一个连接尝试同时写入。
常见原因和处理方案:
- 多线程并发用同一个FMDatabase实例:改成FMDatabaseQueue统一调度。
- 事务持续时间过长:检查事务block里是不是写入了耗时较长的网络请求或者大循环,建议先把数据准备好再开事务。
- 没有释放旧连接:检查是否有别的地方持有数据库连接没有关闭,可以全局搜索sqlite3_open和[db open]的调用点。
- WAL模式下读与写冲突:设置busy_timeout,让连接等待锁释放而不是立刻报错。FMDB可以在打开后执行PRAGMA busy_timeout = 3000。
我在开发中遇到database is locked,大部分排查思路是:先确认是否所有写操作都收拢到了同一队列,再确认事务是否有异常未回滚,最后才是调整锁超时参数。
5.3 SQL注入风险在客户端同样值得重视
很多iOS开发者对SQL注入不以为然,觉得客户端没那么多安全威胁。但其实客户端收到的数据来自于服务端和用户的输入,恶意的字符串通过SQL动态拼接,完全可能破坏本地数据库的完整性。
看一个反例:
objectivec复制// 危险写法
NSString *sql = [NSString stringWithFormat:@"SELECT * FROM user_info WHERE nickname = '%@'", inputName];
如果inputName传入的是'; DELETE FROM user_info; --,那这条拼接出来的SQL足以删掉整张表。正确的做法是使用参数绑定,不管是原生API还是FMDB,都用?占位符:
objectivec复制// 安全写法
FMResultSet *result = [db executeQuery:@"SELECT * FROM user_info WHERE nickname = ?", inputName];
5.4 数据库版本迁移,字段变更必备方案
本地数据库上线之后一定会遇到结构变更,最典型的是加字段。直接在CREATE TABLE语句里改会发现已有的库文件不会自动新增列,所以必须做版本迁移。
我的实践方案是维护一个user_version字段,在FMDB中可以通过PRAGMA user_version来记录当前数据库版本:
objectivec复制[db executeUpdate:@"PRAGMA user_version = 1"];
启动时读取当前版本号,根据版本号依次执行迁移脚本:
objectivec复制int currentVersion = [db intForQuery:@"PRAGMA user_version"];
if (currentVersion < 2) {
[db executeUpdate:@"ALTER TABLE user_info ADD COLUMN city TEXT"];
[db executeUpdate:@"PRAGMA user_version = 2"];
}
迁移脚本升级注意两点:一是迁移过程同样要用事务包裹,中途失败要回滚,否则会出现半迁移状态。二是不要在迁移里破坏已有数据,先备份再迁移更为稳妥。
5.5 数据库文件查看与调试技巧
我调试本地数据库最常用的工具是终端里的sqlite3命令和图形化工具。模拟器沙盒路径可以直接在Finder里打开,数据库文件拷贝出来之后用工具打开检查表结构和数据。抓包调试时用Charles配合SQLite库可以分析数据交互,但本地数据库的问题更多还是直接用命令行工具排查:
bash复制sqlite3 app.db
.tables
.schema user_info
SELECT count(*) FROM user_info;
PRAGMA integrity_check;
PRAGMA integrity_check非常有用,它快速检查数据库文件的完整性,发现异常就打印错误描述。我每次怀疑数据库损坏时,第一件事就是跑这条命令。
最后分享一个我自己的习惯:所有面向SQLite的写操作都纳入统一的数据访问层,禁止业务代码里直接执行SQL字符串。这样既方便做日志打点、统计慢SQL,也能统一管控事务和备份逻辑。别贪图一时省事在业务里到处塞SQLite调用,等数据层出了问题想排查,你会后悔的。
