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

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券