iOS本地数据存储

前言

工作需要,特意准备一篇入门文章,为新人开发者介绍常见的数据存储。

正文

数据存储

数据存储本质就是运行时的对象保存在文件、数据库中。数据存储可以分为两步:首先是将对象转换成二进制数据,这一步也叫序列化;相反,将二进制数据转换成对象则称为反序列化;然后是考虑二进制数据如何保存和读取。

沙盒目录

iOS系统为每个App分配了独立的数据目录,App只能对自己的目录进行操作,这个目录所在被称为沙盒目录。
一个应用的沙盒包括下面三个部分:应用目录、沙盒目录、iCloud目录。



Documents目录用于保存App的数据,包括App运行时需要的各类文件以及用户的数据等。Documents文件夹可以在连接iTunes时选择备份,通常Documents目录用来存放可以对外的文件。
Library目录用来保存不对外的数据,但同样可以被iTunes备份(Library/Caches目录除外,原因就和目录名一样,里面应该只放Caches)。Library/Caches目录用来放置运行时产生的临时文件以及缓存文件,空间不足时可能会被iOS系统删除。Library/Preferences目录通常用于保存用户的设置等信息,比如我们常用的NSUserDefaults类就会以plist的方式保存在该目录中。
tmp目录用来保存不重要的临时文件,在系统重启后会被清空,容易知道这个也不会被iTunes备份。

// 获取沙盒根目录路径
NSString *homeDir = NSHomeDirectory();
// 获取Documents目录路径
NSString *docDir = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask,YES) firstObject];
//获取Library的目录路径
NSString *libDir = [NSSearchPathForDirectoriesInDomains(NSLibraryDirectory,NSUserDomainMask,YES) lastObject];
// 获取cache目录路径
NSString *cachesDir = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory,NSUserDomainMask,YES) firstObject];
// 获取tmp目录路径
NSString *tmpDir =NSTemporaryDirectory();

思考题🤔,我们工程中的图片资源是不是放在沙盒目录中呢?
答案是工程中的资源文件在NSBundle,而NSBundle会被打包到.ipa文件上传到App Store,而用户安装App时候,会把App放置在应用目录(非沙盒目录)。

NSFileManager

系统提供了NSFileManager类给开发去读取沙盒目录中的文件。
NSFileManager是单例,通过defaultManager方法可以获取:
NSFileManager *fileManager = [NSFileManager defaultManager];
拿到fileManager就可以判断文件是否存在,并且返回是文件还是文件夹:
[fileManager fileExistsAtPath:filepath1 isDirectory:&isDirectory];
遍历文件夹:
[fileManager contentsOfDirectoryAtPath:filePath error:&error];
复制或者移动文件:
[fileManager copyItemAtPath:sourceFilePath toPath:targetFilePath error:nil];
[fileManager moveItemAtPath:sourceFilePath toPath:targetFilePath error:nil];
更详细的API可以自行查看NSFileManager.h文件。

NSBundle

在用NSFileManager去读取文件的时候需要提供文件路径,但是有时候我们并不知道资源被放置在哪个目录,此时可以用到NSBundle。
在Xcode编译运行的时候,会把Xcode内的图片、xib、音频等都拷贝到.app文件中。
NSBundle就是系统提供,用来读取这些资源的类。
NSBundle * mainBundle = [NSBundle mainBundle];
这样我们就拿到我们的mainBundle,通过mainBundle我们可以查找对应的资源:
NSString *path =[mainBundle pathForImageResource:@"some_pic_name"]; // 查找图片地址
也可以通过mainBundle直接加载xib:
[[NSBundle mainBundle] loadNibNamed:@"SSProgressView" owner:self options:nil];

思考题🤔,通过CocoaPods安装的Pod库,要如何读取其资源?
NSString *path = [[NSBundle mainBundle] pathForResource:@"SSTestPod" ofType:@"bundle"];
NSBundle *podBundle = [NSBundle bundleWithPath:path];

NSUserDefault

iOS系统提供的持久化存储数据的类,该方法是多线程安全的单例,在沙盒中的存储是用plist进行保存。
如果是NSString、NSNumber、NSData等基础类型可以直接存储在NSUserDefault,如果是自定义对象则需要实现NSCoding进行对象的序列化和反序列化。

比如说存储一个integer数据:
[[NSUserDefaults standardUserDefaults] setInteger:1234 forKey:@"key_for_test"];
读取存储的数据:
[[NSUserDefaults standardUserDefaults] integerForKey:@"key_for_test"];

NSUserDefault会由系统自动将数据写入plist中,iOS的老版本也可以调用synchronize方法手动同步,避免写入数据后系统还没将其写入plist而用户退出应用(最新的iOS版本已经不需要)。

实际开发中,由于NSUserDefault的性能较差并且同步也不及时,多用第三库MMKV来取代NSUserDefault,但是因为某些系统库仍会读取NSUserDefault上的值,NSUserDefault在工程中仍占有一席之地。

SQLite3和FMDB

SQLite3是一款轻型的关系型数据库,在移动端中广泛应用。
SQLite3基于C语言实现,OC可以直接兼容,iOS系统也自带了SQLite3,提供的方法是直接操作数据库。
创建/打开数据库:

NSString *path = [NSHomeDirectory() stringByAppendingPathComponent:@"test_db.sqlite"];
sqlite3 *database;
sqlite3_open([path UTF8String], &database);

建表:

const char *createSQL = "create table if not exists test_table_name(id integer primary key autoincrement,test_name_key char)";
char *error;
sqlite3_exec(database, createSQL, NULL, NULL, &error);

执行sql语句:

// 比较复杂的方法:对SQL语句执行预编译
int sqlite3_prepare(sqlite3 *db, const char *sql,int byte,sqlite3_stmt **stmt,const char **tail);

// 具体过程
sqlite3_stmt *stmt;
const char *insertSQL = "insert into test_table_name(test_name_key) values('anyname')";
int insertResult = sqlite3_prepare_v2(database, insertSQL, -1, &stmt, nil);
if (insertResult == SQLITE_OK) {
    sqlite3_step(stmt);
}

结束处理

// stmt是中间创建的结果,需要销毁
sqlite3_finalize(stmt);     
// 关闭数据库,释放文件句柄等资源
sqlite3_close(database);

可以感觉得出来,sqlite3的原生语言是C语言,接口的调用与OC风格不太一样,感觉较为复杂。

FMDB

FMDB对SQLite数据库进行封装,开放OC的接口便于开发者接入,是很普遍使用的iOS第三方数据库。
GitHub仓库地址,也可以使用pod接入。

三个核心类:
1、FMDatabase:表示一个SQLite数据库,用于执行sql语句;
2、FMResultSet:FMDatabase执行查询得到的结果集;
3、FMDatabaseQueue:多线程用的查询或更新队列;

FMDB的使用:

FMDatabase *db = [FMDatabase databaseWithPath:path]; // create db
[db open]; // open
// create table
NSString *createSqlStr = @"create table if not exists test_table_name(id integer primary key autoincrement,test_name_key char)";
[db executeUpdate:createSqlStr];
// insert table
NSString *insertSqlStr = @"insert into test_table_name(test_name_key) values('anyname')";
[db executeUpdate:insertSqlStr];

sql还可以使用?参数,然后在执行的时候填写具体的值:

NSString *insertSqlStr2 = @"insert into test_table_name(test_name_key) values(?)";
[db executeUpdate:insertSqlStr2, @"another_name"];

查询也很方便,可以结合FMDatabaseQueue来看:

FMDatabaseQueue *sqlQueue = [FMDatabaseQueue databaseQueueWithPath:path];
[sqlQueue inDatabase:^(FMDatabase * _Nonnull db) {
    NSString *selectSqlStr = @"select id, test_name_key FROM test_table_name";
    FMResultSet *result = [db executeQuery:selectSqlStr];
    while ([result next]) {
        int value_id = [result intForColumn:@"id"];
        NSString *value_name = [result stringForColumn:@"test_name_key"];
        NSLog(@"id:%d, name:%@", value_id, value_name);
    }
}];

FMDatabaseQueue是使所有操作都在同一个队列进行,避免多线程操作数据库,引起数据异常。

CoreData

如果不想使用第三方库,也可以使用iOS系统提供的CoreData框架。
CoreData的接口更加简化,部分可视化操作,对象代码自动生成等。

表结构(可视化操作,代码生成):

根据这个表结构,先选中CoreData的模型文件,在Xcode的Editor有Create NSManagedObject Subclass的选项,选中后会自动生成类的代码,如下:

@interface User (CoreDataProperties)
+ (NSFetchRequest<User *> *)fetchRequest;
@property (nonatomic) int16_t gender;
@property (nullable, nonatomic, copy) NSString *name;
@end

CoreData的具体使用:

//从本地加载对象模型
NSString *modelPath = [[NSBundle mainBundle] pathForResource:@"LearnCoreData" ofType:@"momd"];
NSManagedObjectModel *model = [[NSManagedObjectModel alloc] initWithContentsOfURL:[NSURL fileURLWithPath:modelPath]];
// 创建沙盒中的数据库
NSPersistentStoreCoordinator* coord = [[NSPersistentStoreCoordinator alloc] initWithManagedObjectModel:model];
NSString *path = [NSHomeDirectory() stringByAppendingPathComponent:@"database.sqlite"];
[coord addPersistentStoreWithType:NSSQLiteStoreType configuration:nil URL:[NSURL fileURLWithPath:path] options:nil error:nil];
// 数据库关联缓存
NSManagedObjectContext* objContext = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
objContext.persistentStoreCoordinator = coord;

数据的插入操作:

// 数据插入
User *user = [NSEntityDescription insertNewObjectForEntityForName:@"User" inManagedObjectContext:objContext];
user.name = [NSString stringWithFormat:@"name_%d", arc4random_uniform(100)];
user.gender = arc4random_uniform(2);
NSError *error;
[objContext save:&error];

数据查询操作:

NSFetchRequest *fetch = [[NSFetchRequest alloc] initWithEntityName:@"User"];
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"gender=1"]; //查询条件
fetch.predicate = predicate;
NSArray *results = [objContext executeFetchRequest:fetch error:nil];
for (int i = 0; i < results.count; ++i) {
    User *selectedUser = results[i];
    NSLog(@"name…:%@", selectedUser.name);
}

配合前面所学的知识,我们从沙盒可以导出项目中实际使用的数据库。

用SQLPro for SQLite打开,就可以看到里面的具体信息:(这在分析竞品的时候很有用)

Keychain

从上文我们可以知道,保存在沙盒目录的数据也是不安全的,用户可能会导出沙盒数据进行分析。
有没有什么保存方式是更安全的呢?
iOS给出的答案是keychain。
keychain是iOS提供给App存储敏感和安全相关数据用的工具。keychain同样会被iTunes备份,即使App重装仍能读取到上次保存的结果。为了保证数据安全,keychain内的数据都是经过加密。

keychain的使用
1、打开keychain的开关。

2、import <Security/Security.h>
3、使用API;

// SELECT
OSStatus SecItemCopyMatching(CFDictionaryRef query, CFTypeRef *result);
// ADD
OSStatus SecItemAdd(CFDictionaryRef attributes, CFTypeRef *result);
// UPDATE
OSStatus SecItemUpdate(CFDictionaryRef query, CFDictionaryRef attributesToUpdate);
// DELETE
OSStatus SecItemDelete(CFDictionaryRef query);

这些api非常不友好,幸好苹果官方有提供demo,第三方开发者也有人尝试去封装这些接口,我们以
KeychainWrapper为例,来看看封装后更简单的接口。

- (void)savePassword:(NSString *)password;
- (BOOL)deleteItem;

- (NSString *)readPassword;
//返回当前accessGroup下的service的所有Keychain Item
+ (NSArray *)passwordItemsForService:(NSString *)service accessGroup:(NSString *)accessGroup;

比之前更加贴近OC的语法。

具体的使用样例:

KeychainWrapper *wrapper = [[KeychainWrapper alloc] initWithSevice:kKeychainService account:self.account accessGroup:kKeychainAccessGroup];
NSString *saveStr = [wrapper readPassword];
if (!saveStr) {
    [wrapper savePassword:@"test_password"];
}
NSLog(@"saveStr:%@", saveStr);

只要保存在keychain,即使应用卸载重装,仍旧能读取到该值。

具体的逻辑可见GitHub

对象序列化

前面介绍了各种存储的工具,那么如何把运行中的对象序列化成第三方库呢?
有的开发者会使用系统提供的NSCoding协议手动添加字段,有的开发者会使用Runtime自动实现NSCoding,有的开发者会使用成熟的第三方库(例如YYModel),下面分别介绍这几种序列化的方式。

NSCoding是系统提供的序列化协议,在对象转换为二进制的时候,会通过NSCoding的方法回调开发者。

@protocol NSCoding
- (void)encodeWithCoder:(NSCoder *)aCoder;
- (nullable instancetype)initWithCoder:(NSCoder *)aDecoder; // NS_DESIGNATED_INITIALIZER
@end

使用样例:

@interface SSUser : NSObject <NSCoding>

@property (nonatomic, assign) NSInteger gender;
@property (nonatomic, strong) NSString *userName;


- (instancetype)initWithCoder:(NSCoder *)aDecoder {
    self = [super init];
    self.gender = [[aDecoder decodeObjectForKey:@"gender"] integerValue];
    self.userName = [aDecoder decodeObjectForKey:@"userName"];
    return self;
}

- (void)encodeWithCoder:(NSCoder *)aCoder {
    [aCoder encodeObject:@(self.gender) forKey:@"gender"];
    [aCoder encodeObject:self.userName forKey:@"userName"];
}
@end

上面的方式随着属性增多,代码越来越臃肿,于是有的开发者便利用Runtime的特性,读取类的属性名,自动完成这个过程。
随着iOS的社区发展,有一个序列化的第三方库脱颖而出,那就是YYModel。

YYModel具有几大特点:
1、利用iOS的Runtime特点,无需继承;
2、安全转换数据类型,常见Crash都进行了保护;
3、扩展性强,提供多种容器扩展;

YYModel的使用:
1、安装Pod库,pod 'YYModel'
2、import<NSObject+YYModel.h>
在对象添加YYModel的声明。

@interface SSUser : NSObject <YYModel>

@property (nonatomic, assign) NSInteger gender;
@property (nonatomic, strong) NSString *userName;

@end

3、将字典转换会对象;

NSDictionary *dic = @{
                  @"gender":@0,
                  @"userName": @"test_name",
                    };
SSUser *user = [SSUser modelWithDictionary:dic];

YYModel还提供丰富的特性,比如说自定义属性名映射、容易类型转换、自定义类的数据映射。

以自定义属性名映射为例:

+ (NSDictionary *)modelCustomPropertyMapper {
    return @{@"userName":@"name"};
}

YYModel原理和更多进阶使用技巧可以见GitHub

总结

iOS的本地数据存储,其实就是内存数据的序列化和反序列化。

通常我们的数据都会保存在沙盒目录中,读取的时候可以直接指定路径,也可以用NSFileManager去查找和遍历目录;我们工程中的资源文件会存在应用目录,需要用NSBundle去读取。

APP在运行过程中,有时候需要临时保存一些变量,在下次运行时读取,此时可以用轻量级的持久化工具NSUserDefault,如果数据量比较大则需要考虑使用数据进行存储。SQLite3是iOS中最常用的数据库,通常我们会第三方封装库FMDB来操作,简化代码逻辑。

如果涉及到安全相关的敏感数据,则不应该保存在文件、数据库等可以被抓取的地方。此时可以使用iOS提供的keychain对敏感数据进行保存。keychain的数据是经过加密处理,具有较高的安全性。

在将对象转换成二进制数据,以及将二进制数据转换成对象时,可以使用系统提供的NSCoding协议,也可以使用第三方库YYModel。

所有代码GitHub可见,地址

CoreData注意事项

在生成代码的时候,可能会如下的提示:

看详细的编译错误并没有额外的信息,仍是符号冲突。

duplicate symbol _OBJC_CLASS_$_CDUser in: 
    /Users/loyinglin/Library/Developer/Xcode/DerivedData/LearnDatabase-dkstmlwuljogjqbnffnrdaqurvyv/Build/Intermediates.noindex/LearnDatabase.build/Debug-iphonesimulator/LearnDatabase.build/Objects-normal/x86_64/CDUser+CoreDataClass.o 
duplicate symbol _OBJC_METACLASS_$_CDUser in: 
    /Users/loyinglin/Library/Developer/Xcode/DerivedData/LearnDatabase-dkstmlwuljogjqbnffnrdaqurvyv/Build/Intermediates.noindex/LearnDatabase.build/Debug-iphonesimulator/LearnDatabase.build/Objects-normal/x86_64/CDUser+CoreDataClass.o 
ld: 2 duplicate symbols for architecture x86_64 
clang: error: linker command failed with exit code 1 (use -v to see invocation) 

但是在工程中,仅仅只有一个CDUser+CoreDataProperties.m,并没有其他CDUser的类。
尝试把CDUser+CoreDataProperties.m从compile source中移除,工程中仍保留CDUser+CoreDataProperties.h文件,结果编译可以通过。
检查工程的build settings也没有有用的信息,最后打开DerivedData中找到对应的目录,结果找到下面的CoreDataGenerated文件夹:

从名字上可以得知,这也是CoreData自动生成!
经过一番搜索,终于找到CoreData对应的设置。

附录

苹果官方文档-File System Programming Guide

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容