【原】FMDB源码阅读(一)

【原】FMDB源码阅读(一)

本文转载请注明出处 —— polobymulberry-博客园html

1. 前言


说实话,以前的SDWebImageAFNetworking这两个组件我仍是使用过的,可是对于FMDB组件我是一点都没用过。好在FMDB源码中的main.m文件提供了大量的示例,何况网上也有不少最佳实践的例子,我就不在这献丑了。咱们先从一个最简单的FMDB的例子开始:sql

// 找到用户目录下的Documents文件夹位置
NSString* docsdir = [NSSearchPathForDirectoriesInDomains( NSDocumentDirectory, NSUserDomainMask, YES) lastObject];
// 将user.sqlite放到Documents文件夹下,并生成user.sqlite的绝对路径
NSString* dbpath = [docsdir stringByAppendingPathComponent:@"user.sqlite"];
// 根据user.sqlite的绝对路径获取到一个FMDatabase对象,其实就是一个封装了的SQLite数据库对象
FMDatabase* db = [FMDatabase databaseWithPath:dbpath];
// 打开该数据库
[db open];
// 执行SQL语句 - select * from people
FMResultSet *rs = [db 
executeQuery
:@"select * from people"];
// 利用next函数,循环输出结果
while ([rs next]) {
    NSLog(@"%@ %@",
        [rs stringForColumn:@"firstname"], 
        [rs stringForColumn:@"lastname"]);
}
// 关闭该数据库
[db close];

很简单是吧,甚至我以为上面我写的注释都多余了。确实,FMDB说白了就是对SQLite数据库的C/C++接口进行了一层封装,固然功能也更为强大,好比多线程操做,另外FMDB接口要比原生的SQLite接口简洁不少。下面咱们就上面的例子研究下FMDB的基本流程。数据库

2. FMDB的最基本流程(结合上面例子)


咱们先看看上面代码中我用蓝色粗体高亮的部分,研究下其具体实现。编程

2.1 + [FMDatabase databaseWithPath:]

// 核心其实仍是调用了+[FMDataBase initWithPath:]函数,下面会详解
+ (instancetype)databaseWithPath:(NSString*)aPath {
    // FMDBReturnAutoReleased是为了让FMDB兼容MRC和ARC,具体细节看下其宏定义就明白了
    return FMDBReturnAutoreleased([[self alloc] initWithPath:aPath]);
}

/** 初始化一个FMDataBase对象
 根据path(aPath)来建立一个SQLite数据库。对应的aPath参数有三种情形:
 
 1. 数据库文件路径:不为空字符串,不为nil。若是该文件路径不存在,那么SQLite会给你新建一个
 2. 空字符串@"":将在外存临时给你建立一个空的数据库,而且若是该数据库链接释放,那么对应数据库会自动删除
 3. nil:会在内存中建立数据库,随着该数据库链接的释放,也会释放该数据库。
 */
- (instancetype)initWithPath:(NSString*)aPath {
    // SQLite支持三种线程模式,sqlite3_threadsafe()函数的返回值能够肯定编译时指定的线程模式。
 // 三种模式分别为1.单线程模式 2.多线程模式 3.串行模式 其中对于单线程模式,sqlite3_threadsafe()返回false
 // 对于另外两个模式,则返回true。这是由于单线程模式下没有进行互斥(mutex),因此多线程下是不安全的
    assert(sqlite3_threadsafe());     
    self = [super init];
   // 不少属性后面再提。不过这里值得注意的是_db竟然赋值为nil,也就是说真正构建_db不是在initWithPath:这个函数中,这里透露下,其实做者是将构建部分代码放到了open函数中if (self) {
        _databasePath               = [aPath copy];
        _openResultSets             = [[NSMutableSet alloc] init];
        _db                         = nil;
        _logsErrors                 = YES;
        _crashOnErrors              = NO;
        _maxBusyRetryTimeInterval   = 2;
    }
    
    return self;
}

2.2 - [FMDatabase open]

上面提到过+ [FMDatabase databaseWithPath:]和- [FMDatabase initWithPath:]本质上只是给了数据库一个名字,并无真实建立或者获取数据库。这里的open函数才是真正获取到数据库,其本质上也就是调用SQLite的C/C++接口 – sqlite3_open()缓存

sqlite3_open(const char *filename, sqlite3 **ppDb)安全

该例程打开一个指向 SQLite 数据库文件的链接,返回一个用于其余 SQLite 程序的数据库链接对象。session

若是 filename 参数是 NULL 或 ':memory:',那么 sqlite3_open() 将会在 RAM 中建立一个内存数据库,这只会在 session 的有效时间内持续。数据结构

若是文件名 filename 不为 NULL,那么 sqlite3_open() 将使用这个参数值尝试打开数据库文件。若是该名称的文件不存在,sqlite3_open() 将建立一个新的命名为该名称的数据库文件并打开。多线程

- (BOOL)open {
    if (_db) {
        return YES;
    }
    
    int err = sqlite3_open([self sqlitePath], (sqlite3**)&_db );
    if(err != SQLITE_OK) {
        NSLog(@"error opening!: %d", err);
        return NO;
    }
    // 若_maxBusyRetryTimeInterval大于0,那么就调用setMaxBusyRetryTimeInterval:函数
    // setMaxBusyRetryTimeInterval:函数主要是调用sqlite3_busy_handler来处理其余线程已经在操做数据库的状况,默认_maxBusyRetryTimeInterval为2。
    // 具体该参数有什么用,下面在FMDBDatabaseBusyHandler函数中会详解。
    if (_maxBusyRetryTimeInterval > 0.0) {
        [self setMaxBusyRetryTimeInterval:_maxBusyRetryTimeInterval];
    }
    
    return YES;
}

- (void)setMaxBusyRetryTimeInterval:(NSTimeInterval)timeout {
    
    _maxBusyRetryTimeInterval = timeout;
    
    if (!_db) {
        return;
    }
    // 处理的handler设置为FMDBDatabaseBusyHandler这个函数
    if (timeout > 0) {
        sqlite3_busy_handler(_db, &FMDBDatabaseBusyHandler, (__bridge void *)(self));
    }
    else {
        // 不使用任何busy handler处理
        sqlite3_busy_handler(_db, nil, nil);
    }
}

这里须要提一下sqlite3_busy_handler这个函数:并发

int sqlite3_busy_handler(sqlite3*, int(*)(void*,int), void*);

第一个参数是告知哪一个数据库须要设置busy handler。

第二个参数是其实就是回调函数(busy handler)了,当你调用该回调函数时,需传递给它的一个void*的参数的拷贝,也即sqlite3_busy_handler的第三个参数;另外一个须要传给回调函数的int参数是表示此次锁事件,该回调函数被调用的次数。若是回调函数返回0时,将再也不尝试再次访问数据库而返回SQLITE_BUSY或者SQLITE_IOERR_BLOCKED。若是回调函数返回非0, 将会不断尝试操做数据库。

总结:程序运行过程当中,若是有其余进程或者线程在读写数据库,那么sqlite3_busy_handler会不断调用回调函数,直到其余进程或者线程释放锁。得到锁以后,不会再调用回调函数,从而向下执行,进行数据库操做。该函数是在获取不到锁的时候,以执行回调函数的次数来进行延迟,等待其余进程或者线程操做数据库结束,从而得到锁操做数据库。

你们也看出来了,sqlite3_busy_handler函数的关键就是这个回调函数了,此处做者定义的是一个名叫FMDBDatabaseBusyHandler的函数做为其busy handler。

// 注意:appledoc(生成文档的软件)中,对于有具体实现的C函数,好比下面这个函数,
// 是有bug的。因此你在生成文档时,忽略.m文件。

// 该函数就是简单调用sqlite3_sleep来挂起进程
static int FMDBDatabaseBusyHandler(void *f, int count) {
    FMDatabase *self = (__bridge FMDatabase*)f;
    // 若是count为0,表示的第一次执行回调函数
    // 初始化self->_startBusyRetryTime,供后面计算delta使用
    if (count == 0) {
        self->_startBusyRetryTime = [NSDate timeIntervalSinceReferenceDate];
        return 1;
    }
    // 使用delta变量控制执行回调函数的次数,每次挂起50~100ms
    // 因此maxBusyRetryTimeInterval的做用就在这体现出来了
    // 当挂起的时长大于maxBusyRetryTimeInterval,就返回0,并中止执行该回调函数了
    NSTimeInterval delta = [NSDate timeIntervalSinceReferenceDate] - (self->_startBusyRetryTime);
    
    if (delta < [self maxBusyRetryTimeInterval]) {
         // 使用sqlite3_sleep每次当前线程挂起50~100ms
        int requestedSleepInMillseconds = (int) arc4random_uniform(50) + 50;
        int actualSleepInMilliseconds = sqlite3_sleep(requestedSleepInMillseconds); 
        // 若是实际挂起的时长与想要挂起的时长不一致,多是由于构建SQLite时没将HAVE_USLEEP置为1
        if (actualSleepInMilliseconds != requestedSleepInMillseconds) {
            NSLog(@"WARNING: Requested sleep of %i milliseconds, but SQLite returned %i. Maybe SQLite wasn't built with HAVE_USLEEP=1?", requestedSleepInMillseconds, actualSleepInMilliseconds);
        }
        return 1;
    }
    
    return 0;
}

2.3 - [FMDatabase executeQuery:withArgumentsInArray:orDictionary:orVAList:](重点)

为何不讲 - [FMDatabase executeQuery:]?由于- [FMDatabase executeQuery:]等等相似的函数,最终都是对- [FMDatabase executeQuery:withArgumentsInArray:orDictionary:orVAList:]的简单封装。该函数比较关键,主要是针对查询的sql语句。

- (FMResultSet *)executeQuery:(NSString *)sql withArgumentsInArray:(NSArray*)arrayArgs orDictionary:(NSDictionary *)dictionaryArgs orVAList:(va_list)args {
    // 判断当前是否存在数据库以供操做
    if (![self databaseExists]) {
        return 0x00;
    }
    // 若是当前线程已经在使用数据库了,那就输出正在使用的警告
    if (_isExecutingStatement) {
        [self warnInUse];
        return 0x00;
    }
    
    _isExecutingStatement = YES;
    
    int rc                  = 0x00; 
    sqlite3_stmt *pStmt     = 0x00; // sqlite的prepared语句类型
    FMStatement *statement  = 0x00; // 对sqlite3_stmt的简单封装,在实际应用中,你不该直接操做FMStatement对象
    FMResultSet *rs         = 0x00; // FMResultSet对象是用来获取最终查询结果的
    // 须要追踪sql执行状态的话,输出执行状态
    if (_traceExecution && sql) {
        NSLog(@"%@ executeQuery: %@", self, sql);
    }
    // 调用sql语句以前,首先要将sql字符串预处理一下,转化为SQLite可用的prepared语句(预处理语句)
    // 使用sqlite3_prepare_v2来生成sql对应的prepare语句(即pStmt)代价很大
    // 因此建议使用缓存机制来减小对sqlite3_prepare_v2的使用
    if (_shouldCacheStatements) {
        // 获取到缓存中的prepared语句
        statement = [self cachedStatementForQuery:sql];
        pStmt = statement ? [statement statement] : 0x00;
        // prepared语句能够被重置(调用sqlite3_reset函数),而后能够从新绑定参数以便从新执行。
        [statement reset];
    }
    // 若是缓存中没有sql对应的prepared语句,那么只能使用sqlite3_prepare_v2函数进行预处理
    if (!pStmt) {
        
        rc = sqlite3_prepare_v2(_db, [sql UTF8String], -1, &pStmt, 0);
        // 若是生成prepared语句出错,那么就根据是否须要打印错误信息(_logsErrors)以及是否遇到错误直接停止程序执行(_crashOnErrors)来执行出错处理。
        // 最后调用sqlite3_finalize函数释放全部的内部资源和sqlite3_stmt数据结构,有效删除prepared语句
        if (SQLITE_OK != rc) {
            if (_logsErrors) {
                NSLog(@"DB Error: %d \"%@\"", [self lastErrorCode], [self lastErrorMessage]);
                NSLog(@"DB Query: %@", sql);
                NSLog(@"DB Path: %@", _databasePath);
            }
            
            if (_crashOnErrors) {
                NSAssert(false, @"DB Error: %d \"%@\"", [self lastErrorCode], [self lastErrorMessage]);
              // abort()函数表示停止程序执行,直接从调用的地方跳出。
                abort();
            }
            
            sqlite3_finalize(pStmt);
            _isExecutingStatement = NO;
            return nil;
        }
    }
    
    id obj;
    int idx = 0;
    // 获取到pStmt中须要绑定的参数个数
    int queryCount = sqlite3_bind_parameter_count(pStmt); // pointed out by Dominic Yu (thanks!)
    
    // 举一个使用dictionaryArgs的例子
    
/**

 NSMutableDictionary 
*dictionaryArgs = [NSMutableDictionary dictionary]; [dictionaryArgs setObject: @"Text1" forKey:@"a" ]; [db executeQuery:@"select * from namedparamcounttest where a = :a" withParameterDictionary:dictionaryArgs]; // 注意相似:AAA前面有冒号的就是参数 // 其余的参数形式如:"?", "?NNN", ":AAA", "$AAA", 或 "@AAA" */
    if (dictionaryArgs) {
        
        for (NSString *dictionaryKey in [dictionaryArgs allKeys]) {
            
            // 在每一个dictionaryKey以前加上冒号,好比上面的a -> :a,方便获取参数在prepared语句中的索引
            NSString *parameterName = [[NSString alloc] initWithFormat:@":%@", dictionaryKey];
            // 查看执行情况
            if (_traceExecution) {
                NSLog(@"%@ = %@", parameterName, [dictionaryArgs objectForKey:dictionaryKey]);
            }
            
            // 在prepared语句中查找对应parameterName的参数索引值namedIdx
            int namedIdx = sqlite3_bind_parameter_index(pStmt, [parameterName UTF8String]);
            
            FMDBRelease(parameterName);
             // 能够利用索引namedIdx获取对应参数,再使用bindObject:函数将dictionaryArgs保存的value绑定给对应参数
            if (namedIdx > 0) {
                [self bindObject:[dictionaryArgs objectForKey:dictionaryKey] toColumn:namedIdx inStatement:pStmt];
                // 使用这个idx来判断sql中的全部参数值是否都绑定上了
                idx++;
            }
            else {
                NSLog(@"Could not find index for %@", dictionaryKey);
            }
        }
    }
    else {
        
        while (idx < queryCount) {
            // 使用arrayArgs的例子
            /**
             [db executeQuery:@"insert into testOneHundredTwelvePointTwo values (?, ?)" withArgumentsInArray:[NSArray arrayWithObjects:@"one", [NSNumber numberWithInteger:2], nil]];
             */
            if (arrayArgs && idx < (int)[arrayArgs count]) {
                obj = [arrayArgs objectAtIndex:(NSUInteger)idx];
            }
        // 使用args的例子,使用args其实就是调用- (FMResultSet *)executeQuery:(NSString*)sql, ...;
        /**
          FMResultSet *rs = [db executeQuery:@"select rowid,* from test where a = ?", @"hi'"];
         */
            else if (args) {
                obj = va_arg(args, id);
            }
            else {
                break;
            }
            
            if (_traceExecution) {
                if ([obj isKindOfClass:[NSData class]]) {
                    NSLog(@"data: %ld bytes", (unsigned long)[(NSData*)obj length]);
                }
                else {
                    NSLog(@"obj: %@", obj);
                }
            }
            
            idx++;
            // 绑定参数值
            [self bindObject:obj toColumn:idx inStatement:pStmt];
        }
    }
    // 若是绑定的参数数目不对,认为出错,并释放资源
    if (idx != queryCount) {
        NSLog(@"Error: the bind count is not correct for the # of variables (executeQuery)");
        sqlite3_finalize(pStmt);
        _isExecutingStatement = NO;
        return nil;
    }
    
    FMDBRetain(statement); // to balance the release below
    // statement不为空,进行缓存
    if (!statement) {
        statement = [[FMStatement alloc] init];
        [statement setStatement:pStmt];
        // 使用sql做为key来缓存statement(即sql对应的prepare语句)
        if (_shouldCacheStatements && sql) {
            [self setCachedStatement:statement forQuery:sql];
        }
    }
    
    // 根据statement和self(FMDatabase对象)构建一个FMResultSet对象,此函数中仅仅是构建该对象,还没使用next等函数获取查询结果
    // 注意FMResultSet中含有如下成员(除了最后一个,其余成员均在此处初始化过了)
    /**
      @interface FMResultSet : NSObject {
           FMDatabase          *_parentDB; // 表示该对象查询的数据库,主要是为了能在FMResultSet本身的函数中索引到正在操做的FMDatabase对象
           FMStatement         *_statement; // prepared语句
    
           NSString            *_query; // 对应的sql查询语句
           NSMutableDictionary *_columnNameToIndexMap; 
       }
     */
    rs = [FMResultSet resultSetWithStatement:statement usingParentDatabase:self];
    [rs setQuery:sql];
    // 将此时的FMResultSet对象添加_openResultSets,主要是为了调试
    NSValue *openResultSet = [NSValue valueWithNonretainedObject:rs];
    [_openResultSets addObject:openResultSet];
    // 并设置statement的使用数目useCount加1,暂时不清楚此成员有何做用,感受也是用于调试
    [statement setUseCount:[statement useCount] + 1];
    
    FMDBRelease(statement);
    // 生成statement的操做已经结束
    _isExecutingStatement = NO;
    
    return rs;
}

2.4 - [FMResultSet nextWithError:]

- [FMResultSet next]函数其实就是对nextWithError:的简单封装。做用就是从咱们上一步open中获取到的FMResultSet对象中读取查询后结果的每一行,交给用户本身处理。读取每一行的方法(即next)其实就是封装了sqlite3_step函数。而nextWithError:主要封装了对sqlite3_step函数返回结果的处理。

int sqlite3_step(sqlite3_stmt*);

sqlite3_prepare函数将SQL命令字符串解析并转换为一系列的命令字节码,这些字节码最终被传送到SQlite3的虚拟数据库引擎(VDBE: Virtual Database Engine)中执行,完成这项工做的是sqlite3_step函数。好比一个SELECT查询操做,sqlite3_step函数的每次调用都会返回结果集中的其中一行,直到再没有有效数据行了。每次调用sqlite3_step函数若是返回SQLITE_ROW,表明得到了有效数据行,能够经过sqlite3_column函数提取某列的值。若是调用sqlite3_step函数返回SQLITE_DONE,则表明prepared语句已经执行到终点了,没有有效数据了。不少命令第一次调用sqlite3_step函数就会返回SQLITE_DONE,由于这些SQL命令不会返回数据。对于INSERT,UPDATE,DELETE命令,会返回它们所修改的行号——一个单行单列的值。

// 返回YES表示从数据库中获取到了下一行数据
- (BOOL)nextWithError:(NSError **)outErr {
    // 尝试步进到下一行
    int rc = sqlite3_step([_statement statement]);
  
    // 对返回结果rc进行处理

    /**
      SQLITE_BUSY 数据库文件有锁
      SQLITE_LOCKED 数据库中的某张表有锁
      SQLITE_DONE sqlite3_step()执行完毕
      SQLITE_ROW sqlite3_step()获取到下一行数据
      SQLITE_ERROR 通常用于没有特别指定错误码的错误,就是说函数在执行过程当中发生了错误,但没法知道错误发生的缘由。
      SQLITE_MISUSE 没有正确使用SQLite接口,好比一条语句在sqlite3_step函数执行以后,没有被重置以前,再次给其绑定参数,这时bind函数就会返回SQLITE_MISUSE。
      */  
    if (SQLITE_BUSY == rc || SQLITE_LOCKED == rc) {
        NSLog(@"%s:%d Database busy (%@)", __FUNCTION__, __LINE__, [_parentDB databasePath]);
        NSLog(@"Database busy");
        if (outErr) {
            // lastError使用sqlite3_errcode获取到错误码,封装成NSError对象返回
            *outErr = [_parentDB lastError];
        }
    }
    else if (SQLITE_DONE == rc || SQLITE_ROW == rc) {
        // all is well, let's return.
    }
    else if (SQLITE_ERROR == rc) {
        // sqliteHandle就是获取到对应FMDatabase对象,而后使用sqlite3_errmsg来获取错误码的字符串
        NSLog(@"Error calling sqlite3_step (%d: %s) rs", rc, sqlite3_errmsg([_parentDB sqliteHandle]));
        if (outErr) {
            *outErr = [_parentDB lastError];
        }
    }
    else if (SQLITE_MISUSE == rc) {
        // uh oh.
        NSLog(@"Error calling sqlite3_step (%d: %s) rs", rc, sqlite3_errmsg([_parentDB sqliteHandle]));
        if (outErr) {
            if (_parentDB) {
                *outErr = [_parentDB lastError];
            }
            else {
                // 若是next和nextWithError函数是在当前的FMResultSet关闭以后调用的
                // 这时输出的错误信息应该是parentDB不存在
                NSDictionary* errorMessage = [NSDictionary dictionaryWithObject:@"parentDB does not exist" forKey:NSLocalizedDescriptionKey];
                *outErr = [NSError errorWithDomain:@"FMDatabase" code:SQLITE_MISUSE userInfo:errorMessage];
            }
            
        }
    }
    else {
        // wtf?
        NSLog(@"Unknown error calling sqlite3_step (%d: %s) rs", rc, sqlite3_errmsg([_parentDB sqliteHandle]));
        if (outErr) {
            *outErr = [_parentDB lastError];
        }
    }
    
    // 若是不是读取下一行数据,那么就关闭数据库
    if (rc != SQLITE_ROW) {
        [self close];
    }
    
    return (rc == SQLITE_ROW);
}

2.5 - [FMDatabase close]

与open函数成对调用。主要仍是封装了sqlite_close函数。

- (BOOL)close {
    // 清除缓存的prepared语句,下面会详解
    [self clearCachedStatements];
    // 关闭全部打开的FMResultSet对象,目前看来这个_openResultSets大概也是用来调试的 
    [self closeOpenResultSets];
    
    if (!_db) {
        return YES;
    }
    
    int  rc;
    BOOL retry;
    BOOL triedFinalizingOpenStatements = NO;
    
    do {
        retry   = NO;
        // 调用sqlite3_close来尝试关闭数据库
        rc      = sqlite3_close(_db);
// 若是当前数据库上锁,那么就先尝试从新关闭(置retry为YES) // 同时还尝试释放数据库中的prepared语句资源
        if (SQLITE_BUSY == rc || SQLITE_LOCKED == rc) {
            if (!triedFinalizingOpenStatements) {
                triedFinalizingOpenStatements = YES;
                sqlite3_stmt *pStmt;
// sqlite3_next_stmt(sqlite3 *pDb, sqlite3_stmt *pStmt
)表示从数据库pDb中对应的pStmt语句开始一个个往下找出相应prepared语句,若是pStmt为nil,那么就从pDb的第一个prepared语句开始。
 // 此处迭代找到数据库中全部prepared语句,释放其资源。
                while ((pStmt = sqlite3_next_stmt(_db, nil)) !=0) {
                    NSLog(@"Closing leaked statement");
                    sqlite3_finalize(pStmt);
                    retry = YES;
                }
            }
        }
        // 关闭出错,输出错误码
        else if (SQLITE_OK != rc) {
            NSLog(@"error closing!: %d", rc);
        }
    }
    while (retry);
    
    _db = nil;
    return YES;
}

//  _cachedStatements是用来缓存prepared语句的,因此清空_cachedStatements就是将每一个缓存的prepared语句释放
// 具体实现就是使用下面那个close函数,close函数中调用了sqlite_finalize函数释放资源
- (void)clearCachedStatements {
    
    for (NSMutableSet *statements in [_cachedStatements objectEnumerator]) {
        // makeObjectsPerformSelector会并发执行同一件事,因此效率比for循环一个个执行要快不少
        [statements makeObjectsPerformSelector:@selector(close)];
    }
    
    [_cachedStatements removeAllObjects];
}
// 注意:此为FMResultSet的close函数
- (void)close {
    if (_statement) {
        sqlite3_finalize(_statement);
        _statement = 0x00;
    }
    
    _inUse = NO;
}

// 清除_openResultSets
- (void)closeOpenResultSets {
    //Copy the set so we don't get mutation errors
    NSSet *openSetCopy = FMDBReturnAutoreleased([_openResultSets copy]);
    // 迭代关闭_openResultSets中的FMResultSet对象
    for (NSValue *rsInWrappedInATastyValueMeal in openSetCopy) {
        FMResultSet *rs = (FMResultSet *)[rsInWrappedInATastyValueMeal pointerValue];
        // 清除FMResultSet的操做
        [rs setParentDB:nil];
        [rs close];
        
        [_openResultSets removeObject:rsInWrappedInATastyValueMeal];
    }
}

3. 总结

本文结合一个基本的FMDB使用案例,介绍了FMDB基本的运做流程和内部实现。总的来讲,FMDB就是对SQLite的封装,因此学习FMDB根本仍是在学习SQLite数据库操做。

4. 参考文章


相关文章
相关标签/搜索