前言
工作需要,特意准备一篇入门文章,为新人开发者介绍常见的数据存储。
正文
数据存储
数据存储本质就是运行时的对象保存在文件、数据库中。数据存储可以分为两步:首先是将对象转换成二进制数据,这一步也叫序列化;相反,将二进制数据转换成对象则称为反序列化;然后是考虑二进制数据如何保存和读取。
沙盒目录
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对应的设置。