跳至主要内容

iOS应用架构谈 本地持久化方案及动态部署

原文引用:http://casatwy.com/iosying-yong-jia-gou-tan-ben-di-chi-jiu-hua-fang-an-ji-dong-tai-bu-shu.html



前言



嗯,你们要的大招。跟着这篇文章一起也发布了CTPersistance和CTJSBridge这两个库,希望大家在实际使用的时候如果遇到问题,就给我提issue或者PR或者评论区。每一个issue和PR以及评论我都会回复的。

持久化方案不管是服务端还是客户端,都是一个非常值得讨论的话题。尤其是在服务端,持久化方案的优劣往往都会在一定程度上影响到产品的性能。然而在客户端,只有为数不多的业务需求会涉及持久化方案,而且在大多数情况下,持久化方案对性能的要求并不是特别苛刻。所以我在移动端这边做持久化方案设计的时候,考虑更多的是方案的可维护和可拓展,然后在此基础上才是性能调优。这篇文章中,性能调优不会单独开一节来讲,而会穿插在各个小节中,大家有心的话可以重点看一下。

持久化方案对整个App架构的影响和网络层方案对整个架构的影响类似,一般都是导致整个项目耦合度高的罪魁祸首。而我也是一如既往的去Model化的实践者,在持久层去Model化的过程中,我引入了Virtual Record的设计,这个在文中也会详细描述。

这篇文章主要讲以下几点:

1、根据需求决定持久化方案

2、持久层与业务层之间的隔离

3、持久层与业务层的交互方式

4、数据迁移方案

5、数据同步方案


另外,针对数据库存储这一块,我写了一个CTPersistance,这个库目前能够完成大部分的持久层需求,同时也是我的Virtual Record这种设计思路的一个样例。这个库可以直接被cocoapods引入,希望大家使用的时候,能够多给我提issue。这里是CTPersistance Class Reference。

根据需求决定持久化方案



在有需要持久化需求的时候,我们有非常多的方案可供选择:NSUserDefault、KeyChain、File,以及基于数据库的无数子方案。因此,当有需要持久化的需求的时候,我们首先考虑的是应该采用什么手段去进行持久化。


NSUserDefault


一般来说,小规模数据,弱业务相关数据,都可以放到NSUserDefault里面,内容比较多的数据,强业务相关的数据就不太适合NSUserDefault了。另外我想吐槽的是,天猫这个App其实是没有一个经过设计的数据持久层的。然后天猫里面的持久化方案就很混乱,我就见到过有些业务线会把大部分业务数据都塞到NSUserDefault里面去,当时看代码的时候我特么就直接跪了。。。问起来为什么这么做?结果说因为写起来方便~你妹。。。


keychain


Keychain是苹果提供的带有可逆加密的存储机制,普遍用在各种存密码的需求上。另外,由于App卸载只要系统不重装,Keychain中的数据依旧能够得到保留,以及可被iCloud同步的特性,大家都会在这里存储用户唯一标识串。所以有需要加密、需要存iCloud的敏感小数据,一般都会放在Keychain。


文件存储


文件存储包括了Plist、archive、Stream等方式,一般结构化的数据或者需要方便查询的数据,都会以Plist的方式去持久化。Archive方式适合存储平时不太经常使用但很大量的数据,或者读取之后希望直接对象化的数据,因为Archive会将对象及其对象关系序列化,以至于读取数据的时候需要Decode很花时间,Decode的过程可以是解压,也可以是对象化,这个可以根据具体<NSCoding>中的实现来决定。Stream就是一般的文件存储了,一般用来存存图片啊啥的,适用于比较经常使用,然而数据量又不算非常大的那种。


数据库存储


数据库存储的话,花样就比较多了。苹果自带了一个Core Data,当然业界也有无数替代方案可选,不过真正用在iOS领域的除了Core Data外,就是FMDB比较多了。数据库方案主要是为了便于增删改查,当数据有状态和类别的时候最好还是采用数据库方案比较好,而且尤其是当这些状态和类别都是强业务相关的时候,就更加要采用数据库方案了。因为你不可能通过文件系统遍历文件去甄别你需要获取的属于某个状态或类别的数据,这么做成本就太大了。当然,特别大量的数据也不适合直接存储数据库,比如图片或者文章这样的数据,一般来说,都是数据库存一个文件名,然后这个文件名指向的是某个图片或者文章的文件。如果真的要做全文索引这种需求,建议最好还是挂个API丢到服务端去做。


总的说一下


NSUserDefault、Keychain、File这些持久化方案都非常简单基础,分清楚什么时候用什么就可以了,不要像天猫那样乱写就好。而且在这之上并不会有更复杂的衍生需求,如果真的要针对它们写文章,无非就是写怎么储存怎么读取,这个大家随便Google一下就有了,我就不浪费笔墨了。由于大多数衍生复杂需求都是通过采用基于数据库的持久化方案去满足,所以这篇文章的重点就数据库相关的架构方案设计和实现。如果文章中有哪些问题我没有写到的,大家可以在评论区提问,我会一一解答或者直接把遗漏的内容补充在文章中。

持久层实现时要注意的隔离



在设计持久层架构的时候,我们要关注以下几个方面的隔离:

1、持久层与业务层的隔离
2、数据库读写隔离
3、多线程控制导致的隔离
4、数据表达和数据操作的隔离


1. 持久层与业务层的隔离



关于Model



在具体讲持久层下数据的处理之前,我觉得需要针对这个问题做一个完整的分析。

在View层设计中我分别提到了胖Model和瘦Model的设计思路,而且告诉大家我更加倾向于胖Model的设计思路。在网络层设计里面我使用了去Model化的思路设计了APIMananger与业务层的数据交互。这两个看似矛盾的关于Model的设计思路在我接下来要提出的持久层方案中其实是并不矛盾,而且是相互配合的。在网络层设计这篇文章中,我对去Model化只给出了思路和做法,相关的解释并不多,是因为要解释这个问题涉及面会比较广,写的时候并不认为在那篇文章里做解释是最好的时机。由于持久层在这里胖Model和去Model化都会涉及,所以我觉得在讲持久层的时候解释这个话题会比较好。

我在跟别的各种领域的架构师交流的时候,发现大家都会或多或少地混用Model和Model Layer的概念,然后往往导致大家讨论的问题最后都不在一个点上,说Model的时候他跟你说Model Layer,那好吧,我就跟你说Model Layer,结果他又在说Model,于是问题就讨论不下去了。我觉得作为架构师,如果不分清楚这两个概念,肯定是会对你设计的架构的质量有很大影响的。

如果把Model说成Data Model,然后跟Model Layer放在一起,这样就能够很容易区分概念了。

Data Model



Data Model这个术语针对的问题领域是业务数据的建模,以及代码中这一数据模型的表征方式。两者相辅相承:因为业务数据的建模方案以及业务本身特点,而最终决定了数据的表征方式。同样操作一批数据,你的数据建模方案基本都是细化业务问题之后,抽象得出一个逻辑上的实体。在实现这个业务时,你可以选择不同的表征方式来表征这个逻辑上的实体,比如字节流(TCP包等),字符串流(JSON、XML等),对象流。对象流又分通用数据对象(NSDictionary等),业务数据对象(HomeCellModel等)。

前面已经遍历了所有的Data Model的形式。在习惯上,当我们讨论Model化时,都是单指对象流中的业务数据对象这一种。然而去Model化就是指:更多地使用通用数据对象去表征数据,业务数据对象不会在设计时被优先考虑的一种设计倾向。这里的通用数据对象可以在某种程度上理解为范型。

Model Layer



Model Layer描述的问题领域是如何对数据进行增删改查(CURD, Create Update Read Delete),和相关业务处理。一般来说如果在Model Layer中采用瘦Model的设计思路的话,就差不多到CURD为止了。胖Model还会关心如何为需要数据的上层提供除了增删改查以外的服务,并为他们提供相应的解决方案。例如缓存、数据同步、弱业务处理等。

我的倾向



我更加倾向于去Model化的设计,在网络层我设计了reformer来实现去Model化。在持久层,我设计了Virtual Record来实现去Model化。

因为具体的Model是一种很容易引入耦合的做法,在尽可能弱化Model概念的同时,就能够为引入业务和对接业务提供充分的空间。同时,也能通过去Model的设计达到区分强弱业务的目的,这在将来的代码迁移和维护中,是至关重要的。很多设计不好的架构,就在于架构师并没有认识到区分强弱业务的重要性,所以就导致架构腐化的速度很快,越来越难维护。

所以说回来,持久层与业务层之间的隔离,是通过强弱业务的隔离达到的。而Virtual Record正是因为这种去Model化的设计,从而达到了强弱业务的隔离,进而做到持久层与业务层之间既隔离同时又能交互的平衡。具体Virtual Record是什么样的设计,我在后面会给大家分析。

2. 数据库读写隔离



在网站的架构中,对数据库进行读写分离主要是为了提高响应速度。在iOS应用架构中,对持久层进行读写隔离的设计主要是为了提高代码的可维护性。这也是两个领域要求架构师在设计架构时要求侧重点不同的一个方面。

在这里我们所谓的读写隔离并不是指将数据的读操作和写操作做隔离。而是以某一条界限为准,在这个界限以外的所有数据模型,都是不可写不可修改,或者修改属性的行为不影响数据库中的数据。在这个界限以内的数据是可写可修改的。一般来说我们在设计时划分的这个界限会和持久层与业务层之间的界限保持一致,也就是业务层从持久层拿到数据之后,都不可写不可修改,或业务层针对这一数据模型的写操作、修改操作都对数据库文件中的内容不产生作用。只有持久层中的操作才能够对数据库文件中的内容产生作用。

在苹果官方提供的持久层方案Core Data的架构设计中,并没有针对读写作出隔离,数据的结果都是以NSManagedObject扔出。所以只要业务工程师稍微一不小心动一下某个属性,NSManagedObjectContext在save的时候就会把这个修改给存进去了。另外,当我们需要对所有的增删改查操作做AOP的切片时,Core Data技术栈的实现就会非常复杂。

整体上看,我觉得Core Data相对大部分需求而言是过度设计了。我当时设计安居客聊天模块的持久层时就采用了Core Data,然后为了读写隔离,将所有扔出来的NSManagedObject都转为了普通的对象。另外,由于聊天记录的业务相当复杂,使用Core Data之后为了完成需求不得不引入很多Hack的手段,这种做法在一定程度上降低了这个持久层的可维护性和提高了接手模块的工程师的学习曲线,这是不太好的。在天猫客户端,我去的时候天猫这个App就已经属于基本毫无持久层可言了,比较混乱。只能依靠各个业务线各显神通去解决数据持久化的需求,难以推动统一的持久层方案,这对于项目维护尤其是跨业务项目合作来说,基本就和车祸现场没啥区别。我现在已经从天猫离职,读者中若是有阿里人想升职想刷存在感拿3.75的,可以考虑给天猫搞个统一的持久层方案。

读写隔离还能够便于加入AOP切点,因为针对数据库的写操作被隔离到一个固定的地方,加AOP时就很容易在正确的地方放入切片。这个会在讲到数据同步方案时看到应用。

3. 多线程导致的隔离




Core Data


Core Data要求在多线程场景下,为异步操作再生成一个NSManagedObjectContext,然后设置它的ConcurrencyType为NSPrivateQueueConcurrencyType,最后把这个Context的parentContext设为Main线程下的Context。这相比于使用原始的SQLite去做多线程要轻松许多。只不过要注意的是,如果要传递NSManagedObject的时候,不能直接传这个对象的指针,要传NSManagedObjectID。这属于多线程环境下对象传递的隔离,在进行架构设计的时候需要注意。


SQLite


纯SQLite其实对于多线程倒是直接支持,SQLite库提供了三种方式:Single Thread,Multi Thread,Serialized。

Single Thread模式不是线程安全的,不提供任何同步机制。Multi Thread模式要求database connection不能在多线程中共享,其他的在使用上就没什么特殊限制了。Serialized模式顾名思义就是由一个串行队列来执行所有的操作,对于使用者来说除了响应速度会慢一些,基本上就没什么限制了。大多数情况下SQLite的默认模式是Serialized。

根据Core Data在多线程场景下的表现,我觉得Core Data在使用SQLite作为数据载体时,使用的应该就是Multi Thread模式。SQLite在Multi Thread模式下使用的是读写锁,而且是针对整个数据库加锁,不是表锁也不是行锁,这一点需要提醒各位架构师注意。如果对响应速度要求很高的话,建议开一个辅助数据库,把一个大的写入任务先写入辅助数据库,然后拆成几个小的写入任务见缝插针地隔一段时间往主数据库中写入一次,写完之后再把辅助数据库删掉。

不过从实际经验上看,本地App的持久化需求的读写操作一般都不会大,只要注意好几个点之后一般都不会影响用户体验。因此相比于Multi Thread模式,Serialized模式我认为是性价比比较高的一种选择,代码容易写容易维护,性能损失不大。为了提高几十毫秒的性能而牺牲代码的维护性,我是觉得划不来的。


Realm


关于Realm我还没来得及仔细研究,所以说不出什么来。

4. 数据表达和数据操作的隔离



这是最容易被忽视的一点,数据表达和数据操作的隔离是否能够做好,直接影响的是整个程序的可拓展性。

长久以来,我们都很习惯Active Record类型的数据操作和表达方式,例如这样:

Record *record = [[Record alloc] init];
record.data = @"data";
[record save];


或者这种:

Record *record = [[Record alloc] init];
NSArray *result = [record fetchList];


简单说就是,让一个对象映射了一个数据库里的表,然后针对这个对象做操作就等同于针对这个表以及这个对象所表达的数据做操作。这里有一个不好的地方就在于,这个Record既是数据库中数据表的映射,又是这个表中某一条数据的映射。我见过很多框架(不仅限于iOS,包括Python, PHP等)都把这两者混在一起去处理。如果按照这种不恰当的方式来组织数据操作和数据表达,在胖Model的实践下会导致强弱业务难以区分从而造成非常大的困难。使用瘦Model这种实践本身就是我认为有缺点的,具体的我在开篇中已经讲过,这里就不细说了。

强弱业务不能区分带来的最大困难在于代码复用和迁移,因为持久层中的强业务对View层业务的高耦合是无法避免的,然而弱业务相对而言只对下层有耦合关系对上层并不存在耦合关系,当我们做代码迁移或者复用时,往往希望复用的是弱业务而不是强业务,若此时强弱业务分不开,代码复用就无从谈起,迁移时就倍加困难。

另外,数据操作和数据表达混在一起会导致的问题在于:客观情况下,数据在view层业务上的表达方式多种多样,有可能是个View,也有可能是个别的什么对象。如果采用映射数据库表的数据对象去映射数据,那么这种多样性就会被限制,实际编码时每到使用数据的地方,就不得不多一层转换。

我认为之所以会产生这样不好的做法原因在于,对象对数据表的映射和对象对数据表达的映射结果非常相似,尤其是在表达Column时,他们几乎就是一模一样。在这里要做好针对数据表或是针对数据的映射要做的区分的关键要点是:这个映射对象的操作着手点相对数据表而言,是对内还是对外操作。如果是对内操作,那么这个操作范围就仅限于当前数据表,这些操作映射给数据表模型就比较合适。如果是对外操作,执行这些操作时有可能涉及其他的数据表,那么这些操作就不应该映射到数据表对象中。

因此实际操作中,我是以数据表为单位去针对操作进行对象封装,然后再针对数据记录进行对象封装。数据表中的操作都是针对记录的普通增删改查操作,都是弱业务逻辑。数据记录仅仅是数据的表达方式,这些操作最好交付给数据层分管强业务的对象去执行。具体内容我在下文还会继续说。

持久层与业务层的交互方式



说到这里,就不得不说CTPersistance和Virtual Record了。我会通过它来讲解持久层与业务层之间的交互方式。

                 -------------------------------------------
| |
| LogicA LogicB LogicC | -------------------------------> View Layer
| \ / | |
-------\-------/------------------|--------
\ / |
\ / Virtual | Virtual
\ / Record | Record
| |
-----------|----------------------|--------
| | | |
Strong Logics | DataCenterA DataCenterB |
| / \ | |
-----------------|-------/-----\-------------------|-------| Data Logic Layer ---
| / \ | | |
Weak Logics | Table1 Table2 Table | |
| \ / | | |
--------\-----/-------------------|-------- |
\ / | |--> Data Persistance Layer
\ / Query Command | Query Command |
| | |
-----------|----------------------|-------- |
| | | | |
| | | | |
| DatabaseA DatabaseB | Data Operation Layer ---
| |
| Database Pool |
-------------------------------------------


我先解释一下这个图:持久层有专门负责对接View层模块或业务的DataCenter,它们之间通过Record来进行交互。DataCenter向上层提供业务友好的接口,这一般都是强业务:比如根据用户筛选条件返回符合要求的数据等。

然后DataCenter在这个接口里面调度各个Table,做一系列的业务逻辑,最终生成record对象,交付给View层业务。

DataCenter为了要完成View层交付的任务,会涉及数据组装和跨表的数据操作。数据组装因为View层要求的不同而不同,因此是强业务。跨表数据操作本质上就是各单表数据操作的组合,DataCenter负责调度这些单表数据操作从而获得想要的基础数据用于组装。那么,这时候单表的数据操作就属于弱业务,这些弱业务就由Table映射对象来完成。

Table对象通过QueryCommand来生成相应的SQL语句,并交付给数据库引擎去查询获得数据,然后交付给DataCenter。


DataCenter 和 Virtual Record


提到Virtual Record之前必须先说一下DataCenter。

DataCenter其实是一个业务对象,DataCenter是整个App中,持久层与业务层之间的胶水。它向业务层开放业务友好的接口,然后通过调度各个持久层弱业务逻辑和数据记录来完成强业务逻辑,并将生成的结果交付给业务层。由于DataCenter处在业务层和持久层之间,那么它执行业务逻辑所需要的载体,就要既能够被业务层理解,也能够被持久层理解。

CTPersistanceTable就封装了弱业务逻辑,由DataCenter调用,用于操作数据。而Virtual Record就是前面提到的一个既能够被业务层理解,也能够被持久层理解的数据载体。

Virtual Record事实上并不是一个对象,它只是一个protocol,这就是它Virtual的原因。一个对象只要实现了Virtual Record,它就可以直接被持久层当作Record进行操作,所以它也是一个Record。连起来就是Virtual Record了。所以,Virtual Record的实现者可以是任何对象,这个对象一般都是业务层对象。在业务层内,常见的数据表达方式一般都是View,所以一般来说Virutal Record的实现者也都会是一个View对象。

我们回顾一下传统的数据操作过程:一般都是先从数据库中取出数据,然后Model化成一个对象,然后再把这个模型丢到外面,让Controller转化成View,然后再执行后面的操作。

Virtual Record也是一样遵循类似的步骤。唯一不同的是,整个过程中,它并不需要一个中间对象去做数据表达,对于数据的不同表达方式,由各自Virtual Record的实现者自己完成,而不需要把这些代码放到Controller,所以这就是一个去Model化的设计。如果未来针对这个数据转化逻辑有复用的需求,直接复用Virtual Record就可以了,十分方便。

用好Virtual Record的关键在于DataCenter提供的接口对业务足够友好,有充足的业务上下文环境。

所以DataCenter一般都是被Controller所持有,所以如果整个App就只有一个DataCenter,这其实并不是一个好事。我见过有很多App的持久层就是一个全局单例,所有持久化业务都走这个单例,这是一种很蛋疼的做法。DataCenter也是需要针对业务做高度分化的,每个大业务都要提供一个DataCenter,然后挂在相关Controller下交给Controller去调度。比如分化成SettingsDataCenter,ChatRoomDataCenter,ProfileDataCenter等,另外要要注意的是,几个DataCenter之间最好不要有业务重叠。如果一个DataCenter的业务实在是大,那就再拆分成几个小业务。如果单个小业务都很大了,那就拆成各个Category,具体的做法可以参考我的框架中CTPersistanceTable和CTPersistanceQueryCommand的实践。

这么一来,如果要迁移涉及持久层的强业务,那就只需要迁移DataCenter即可。如果要迁移弱业务,就只需要迁移CTPersistanceTable。

实际场景



假设业务层此时收集到了用户的筛选条件:

NSDictionary *filter = @{
@"key1":@{
@"minValue1":@(1),
@"maxValue1":@(9),
},
@"key2":@{
@"minValue2":@(1),
@"maxValue2":@(9),
},
@"key3":@{
@"minValue3":@(1),
@"maxValue3":@(9),
},
};


然后ViewController调用DataCenter向业务层提供的接口,获得数据直接展示:

/* in view controller */

NSArry *fetchedRecordList = [self.dataCenter fetchItemListWithFilter:filter]
[self.dataList appendWithArray:fetchedRecordList];
[self.tableView reloadData];

评论

此博客中的热门博文

非公开 app

 1.对用户 用户只能通过特定链接才能使用该 app,其他使用和应用商店 app 一致。 2.对开发 a,以应用商店提交 app 的标准方式提交 app,在备注添加一行,写上“App 将用于非公开分发”,提交 app。 b,  填写非公开 app 申请,https://developer.apple.com/contact/request/unlisted-app/ c,等待审核,整个审核过程会比平常审核慢 2-5 个工作日,后续更新应该会正常。 app分发类型 非公开app ABM App Store 企业包 app分发类型 非公开app ABM App Store 企业包 是否需要https, 自己搭建托管环境 否,文件托管到 App Store 否,文件托管到 App Store 否,文件托管到App Store 是,需要自己托管,需要 https 环境,受信任的 ssl证书 是否可以直接在 App Store 搜索到 否 否 是 否 是否可以在 App Store 展示 应用信息,包括 在应用信息页面 手动点击更新 是 否 是 不上传到App Store无 App Store信息 在应用商店 更新列表点击更新 是 是 是 否 是否需要手 动更新app 开启App Store的自动更新,就自动更新,否则手动更新 开启App Store的自动更新,就自动更新,否则手动更新,只能点我的头像,更新列表里面去更新 开启App Store的自动更新,就自动更新,否则手动更新 必须去 app 里面 点击更新,或者 去下载页面去 下载最新的app 包是否会过期 否,只要账号正常 否,只要账号正常 否,只要账号正常 是,包里面有 两个证书,虽 然只打包进一 个证书文件, 但那个证书文 件关联另一个, (打包的时候一 起使用)打包 的文件有效期1 年,关联的文 件有效期 3 年。打包的时候 是否允许马甲, 重复应用(相似 ui,相似代码) 是 是 否 是 应用商店审核要求 有,除了马甲应用,其他都一样 有,除了马甲应用,其他都一样 不允许马甲应用,其他都一样 不需要审核 是否能用其他 开发者账号上传 我们的定制版应用 未知 可以 不允许 无app store 应用上传权限 哪些版本可以 改成该版本 1.App Store包 2.企业包 1.企业包 1.企业包 ...

非公开 app 分发

非公开 app  简介 app 除了不能搜索到该 app,其他使用和普通的应用商店一致。 只需要使用对应的链接就可以跳转到该 app 。 非公开 app 上传审核流程 1.appstore connect 新建应用,配置和普通上传应用商店(公开应用)完全一致 2.在备注里面加上一行:该 app 属于非公开 app,提交 app 3.去非公开 app 申请链接app 为非公开 app,https://developer.apple.com/contact/request/unlisted-app/ 4.审核被拒,有两部分,一个是普通appstore审核被拒(可能没有),最后还会多上一个非公开 app 审核被拒的部分。等待只有非公开 app 审核被拒的问题后,再等上两天左右就好了。 5.审核通过后,会通过邮件发送该 app 的链接,此链接其实和其他 app 的链接格式一致,并无区别,唯一的区别是该 app 在应用商店搜索不到。

企业包下载安装问题

1. 下载后,去设置 ---> 通用 ---> VPN与设备管理(设备管理),找到对应的企业证书名称,选择信任即可正常打开软件使用。 2. app过期,或者系统时间手动修改到app过期时间,安装包无法正常使用,手动调整系统时间到正常时间即可正常运行。