【重读iOS】数据持久化1:数据库框架

Realm

创建数据库

使用RLMRealm *realm = [RLMRealm defaultRealm];默认的数据库配置,或者使用+ (nullable instancetype)realmWithConfiguration:(RLMRealmConfiguration *)configuration error:(NSError **)error;进行全方位的配置。

调用了方法后数据库创建完成。

创建表

创建表也是在RLMRealm构建的方法里完成的,牛逼的地方在于:它使用objc_copyClassList把所有注册的类给拿到,然后把是从RLMObject继承的类提取出来,把这些类生成对应的表。

所以对于使用者而言,只需要定义数据模型,并且这些模型类从RLMObject继承。

  • 属性就跟普通类一样定义,只是把属性的描述关键词去掉。大概是因为属性的getter/setter全部被重写了,这些属性都没用了。

Realm ignores Objective‑C property attributes like nonatomic, atomic, strong, copy, weak, etc. These aren’t meaningful for Realm storage; it has its own optimized storage semantics.

  • 一对多属性
//这个协议不知道什么用
RLM_ARRAY_TYPE(Book) 
//前一个尖括号是泛型,即数组的元素类型,后一个是协议
@property (nonatomic) RLMArray *books; 
  • 反向关系

图书馆(Library)里有书,属性books,书(Book)可以属于图书馆,属性owner。如果一本书加到一个新的图书馆里,那么是修改了Library的books,但是Book的owner不会发生改变。也就是两个属性有相互影响,让其中一个依赖另一个,这样维护一个属性的修改就可以了。

owner跟随books修改:

//类Book
@property (readonly) RLMLinkingObjects *owners;
+(NSDictionary *)linkingObjectsProperties{
  return @{
           @"owners" : [RLMPropertyDescriptor descriptorWithClass:Library.class propertyName:@"books"]
           };
}
  • 还可以给RLMObject设置主键primaryKey,默认值defaultPropertyValues,忽略的属性ignoredProperties,必要属性requiredProperties,索引indexedProperties。比较有用的是主键和索引。

数据操作

Library *library = [[Library alloc] init];
[realm transactionWithBlock:^{
    [realm addObject:library];
}];

构建和复制跟普通对象一样,存入数据库的时候使用事务。添加后对象就由realm管理了,对它属性的修改必须在写事务内操作,否则奔溃。

Terminating app due to uncaught exception 'RLMException', reason: 'Attempting to modify object outside of a write transaction - call beginWriteTransaction on an RLMRealm instance first.'

删改
[realm transactionWithBlock:^{
     bk.name = @"和谐世界2";
     [realm deleteObject:bk];
}];
查询
//查询全部
[Book allObjects]
//条件查询
RLMResults *results = [Book objectsWhere:@"age == 101"];

where之后的字符串是用来构建NSPredicate的,所以按照它的语法来写。

自动更新

两个对象是对应着数据库里同一个数据,那么其中一个对象修改了,提交给数据库,另外一个对象也会自动跟随修改。

RLMResults *results = [Book objectsWhere:@"age == 119"];
   Book *bk = results.firstObject;
   NSLog(@"1: %@",bk.name);
   
   RLMResults *results2 = [Book objectsWhere:@"age == 119"];
   Book *bk2 = results2.firstObject;
   NSLog(@"2: %@",bk2.name);
   
   [realm transactionWithBlock:^{
       bk.name = [NSString stringWithFormat:@"%@_修改+1",bk.name];
   }];
   NSLog(@"3: %@",bk2.name);

第三次输出的名称就是修改后的名称了。

查询结果也可以自动更新

但是这些更新都限于当前线程,realm不支持跨线程的数据共享,新的线程需要新的RLMRealm对象且从新读取新的数据对象。

数据迁移

RLMRealmConfiguration *config = [RLMRealmConfiguration defaultConfiguration];
    config.schemaVersion = 2;
    
    config.migrationBlock = ^(RLMMigration * _Nonnull migration, uint64_t oldSchemaVersion) {
        //数据迁移代码
        if (oldSchemaVersion < 1) {
            [migration enumerateObjects:Book.className block:^(RLMObject * _Nullable oldObject, RLMObject * _Nullable newObject) {
                
            }];
        }
    };
    [RLMRealmConfiguration setDefaultConfiguration:config];

realm的数据迁移逻辑是:

  • 判断config.schemaVersion的版本是否和数据库相同,不同且大于0执行数据迁移操作
  • 根据当前的类构建一个新的realm(realm的表是由类自动生成的,这一点优势也在这体现出来了),然后执行config.migrationBlock给新的realm填充数据
  • 如果migrationBlock啥也不干,其实新的表也会建起来,只是之前的数据丢失了。所以这里可以理解为两步:1.realm自动完成新的表的构建 2.我们在migrationBlock里完成对新表数据的填充
  • migrationBlock里面有旧的版本号,这样可以一步步的升级上来。简单说就是,有了新版本,加入这一次的迁移代码,下一次的时候不要删除前面的代码,这样就可以逐步更新了,如:
config.migrationBlock = ^(RLMMigration * _Nonnull migration, uint64_t oldSchemaVersion) {
       //数据迁移代码
       if (oldSchemaVersion < 1) {
           //从0更新到1的操作
       }
       if (oldSchemaVersion < 2) {
           //从1更新到2的操作
       }
       if (oldSchemaVersion < 3) {
           //从2更新到3的操作
       }
   };

如果直接从版本0、1、2直接更新到3,也可以把第三步放到前面,看具体需求。

最后realm并不是基于sqlite的,是另写的数据库。

FMDB

FMDB只是针对sqlite做的轻量级的封装,没有模型和表的映射、没有对数据的监控、也没有数据迁移的帮助等等ORM的特性,只是把原本需要执行sql的操作做了一个函数封装。

建库

NSString *dbPath = [NSHomeDirectory() stringByAppendingString:@"/Documents/book.db"];
FMDatabase *database = [FMDatabase databaseWithPath:dbPath];

构建一个FMDatabase对象即可。
不过使用之间要打开数据库,建立数据库连接:[database open]

建表

很普通的执行sql语句:

BOOL state = [database executeStatements:
@"create table if not exists Book (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, age INTEGER)"];
if (!state) {
    NSLog(@"create table error!");
    return;
}

插入数据和更新数据

Book *bk = [[Book alloc] init];
bk.name = @"Big World";
bk.id = 123;
bk.age = 10000;
    
NSDictionary *bkKeyValues = @{
                              @"id":@(bk.id),
                              @"name":bk.name,
                              @"age":@(bk.age)
                              };
[database executeUpdate:
@"insert into Book values(:id, :name, :age)" withParameterDictionary:bkKeyValues];

核心方法是executeUpdate:,这个函数是建立在sqlite3_prepare_v2上的。

新增或者更新数据的时候,可以直接在sql语句内嵌入内容:
insert table Book (name) values('sqlite权威指南')
但如果数据比较长,则在内容部分填入占位字符,表示实际值后面再绑定:

  • sql语句: "insert into Book values(:name, :age)"
  • 使用sqlite3_prepare_v2执行语句
  • 再用sqlite3_bind_xxx系列的函数绑定实际的数据。比如name字段是字符串,使用sqlite3_bind_text(stmt,1,"sqlite权威指南"),这个索引是从1开始算的。

而占位字符有几种格式:?,?number,:string,@string,$string,单纯的问号就是占位,它的索引是自动分配的,第二种number就是指定了索引,后面几种可以通过sqlite3_bind_parameter_index这个函数来查找对应的索引,传入的内容就是:string后面字符的内容。

在FMDB里,如果你更新使用字典来传入数据,就是使用:string这种占位符,通过string内容:1. 从sqlite这边得到索引 2.从字典里拿到字段对应数据 ,把1和2的内容关联起来,使用sqlite3_bind_xxx函数传入。

如果使用数组或者变参的方式传入数据,那么索引和数组里的数据意义对应:

NSArray *infos = @[bk.name, @(bk.age)];
    [database executeUpdate:@"insert into Book (name, age) values(?,?)" withArgumentsInArray:infos];

这里占位可以使用最简单的问号?,那么第一个占位就使用数组里第一个数据,第二个占位就使用第二个数据,依次类推。变参方式传入就是对应第一个参数,第二个参数......

也就是说,这个函数的核心是如何处理绑定参数索引和实际值之间的对应关系,理解了这个问题,这个函数就理解了。

更新数据跟插入数据逻辑一致,也是使用这个方法。

查询数据

查询时的输入逻辑和上面一样,使用sqlite3_prepare_v2处理sql语句,使用占位字符sqlite3_bind_xxx来传入数据。查询时wherelimitoffsetorder by这些的值都可以这么处理。

插入和更新数据时只要执行完操作就可以了,而查询执行完executeQuery函数后,得到的只是FMResultSet对象,还要把数据提取出来:

NSDictionary *queryInfos = @{@"name":@"insert array", @"limitx":@(2), @"order":@"id desc", @"ment":@"desc"};
FMResultSet *result = [database executeQuery:@"select  name, id from Book where name = :name order by :order limit :limitx " withParameterDictionary:queryInfos];

提取数据:

NSMutableArray *models = [[NSMutableArray alloc] init];
while ([result next]) {
    Book *book = [[Book alloc] init];
    book.id = [result longLongIntForColumnIndex:1];
    book.name = [result stringForColumnIndex:0];
//        book.age = [result longLongIntForColumnIndex:0];
    
    [models addObject:book];
}

不断使用next函数调到下一条数据,内部核心是sqlite3_step。然后使用longLongIntForColumnIndex等一系列方法把属性值一个个的提取出来,赋值到对象上。

这里便是原生的sqlite处理中最痛苦的一个环节,对于"一条数据-->一个对象"的转变需要一个字段一个字段的去处理。这样:

  • 字段多写的很痛苦,都是枯燥的代码
  • 对于每个表/模型都需要写一套代码,工作量大,而且一旦模型变动这个代码就要改。

这时就需要ORM来拯救世界了,可惜CoreData除了这个工作之外还有很多的功能,甚至把一些细节都封闭了,导致用起来反而挺麻烦的。

ORM, Object Relational Mapping的简写,从这里的工作里就可以很好的理解这个东西的意思。对象关系映射,把一种对象映射到另一种对象,这里工作的根本就是把数据库里的一条数据(数据库对象)自动的转为我们定义的模型对象。

多线程环境

使用FMDatabaseQueue来调用:

FMDatabaseQueue *dbQueue = [[FMDatabaseQueue alloc] initWithPath:dbPath];
[dbQueue inDatabase:^(FMDatabase * _Nonnull db) {
    [db executeUpdate:@"insert into Book values(200, 'dbQueueInsert', 2013)"];
}];

这是一个保守的方案:

dispatch_sync(_queue, ^() {
  FMDatabase *db = [self database];
   block(db);
   ...
}
...
_queue = dispatch_queue_create([[NSString stringWithFormat:@"fmdb.%@", self] UTF8String], NULL);

这个_queue是一个串行的队列,所以使用FMDatabaseQueue来访问数据库,不管你在哪个线程调用,最后都到这个里_queue里处理,而它又是串行的,所以不会出现并发的情况。没有并发,就没有多线程的各种问题了。

如果你采用这种方案,那么就有建一个全局的FMDatabaseQueue,在哪都用它,不同的queue之间是互发限制的,还是会并发,还是会触发问题。

再者,因为_queue是串行的,而这里又使用了同步的方法dispatch_sync,所以嵌套调用会导致死锁。FMDB做了队列的检测,在同一个队列里调用inDatabase会crash。

//使用dispatch_get_specific获取队列标识
FMDatabaseQueue *currentSyncQueue = (__bridge id)dispatch_get_specific(kDispatchQueueSpecificKey);
assert(currentSyncQueue != self && "inDatabase: was called reentrantly on the same queue, which would lead to a deadlock");

说这个是保守方案是因为,它完全的隔绝了多线程访问的这种操作,每个操作都依次进行,不并发。但实际至少读和读之间是可以共存的。

其他库

key-value数据库YTKKeyValueStore

微信开源数据库wcdb

尝试

sqlite有挺多的特性可以支持ORM的实现,所以参照realm的一些思路,如runtime加载创建表,在FMDB的基础上实现了一个简单的ORM:可以自动建表,脱离sql进行增删改查。考虑到微信开源数据库wcdb和realm都已经是很成熟的方案了,我写了也没多大用,所以没有做太多的完善,只是算作一个尝试,让自己熟悉一下ORM的想法和对sqlite的熟悉。有兴趣的可以看一下TFDatabaseMapper。

你可能感兴趣的:(【重读iOS】数据持久化1:数据库框架)