iOS應用架構談 本地持久化方案及動態部署

iOS應用架構談 開篇 
iOS應用架構談 view層的組織和調用方案 
iOS應用架構談 網絡層設計方案 
iOS應用架構談 本地持久化方案及動態部署 
iOS應用架構談 組件化方案




前言


嗯,你們要的大招。跟着這篇文章一起也發佈了CTPersistanceCTJSBridge這兩個庫,希望大家在實際使用的時候如果遇到問題,就給我提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化都會涉及,所以我覺得在講持久層的時候解釋這個話題會比較好。

我在跟別的各種領域的架構師交流的時候,發現大家都會或多或少地混用ModelModel 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,然後設置它的ConcurrencyTypeNSPrivateQueueConcurrencyType,最後把這個Context的parentContext設爲Main線程下的Context。這相比於使用原始的SQLite去做多線程要輕鬆許多。只不過要注意的是,如果要傳遞NSManagedObject的時候,不能直接傳這個對象的指針,要傳NSManagedObjectID。這屬於多線程環境下對象傳遞的隔離,在進行架構設計的時候需要注意。



SQLite


純SQLite其實對於多線程倒是直接支持,SQLite庫提供了三種方式:Single ThreadMulti ThreadSerialized

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時,他們幾乎就是一模一樣。在這裏要做好針對數據表或是針對數據的映射要做的區分的關鍵要點是:這個映射對象的操作着手點相對數據表而言,是對內還是對外操作。如果是對內操作,那麼這個操作範圍就僅限於當前數據表,這些操作映射給數據表模型就比較合適。如果是對外操作,執行這些操作時有可能涉及其他的數據表,那麼這些操作就不應該映射到數據表對象中。

因此實際操作中,我是以數據表爲單位去針對操作進行對象封裝,然後再針對數據記錄進行對象封裝。數據表中的操作都是針對記錄的普通增刪改查操作,都是弱業務邏輯。數據記錄僅僅是數據的表達方式,這些操作最好交付給數據層分管強業務的對象去執行。具體內容我在下文還會繼續說。




持久層與業務層的交互方式



說到這裏,就不得不說CTPersistanceVirtual 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去調度。比如分化成SettingsDataCenterChatRoomDataCenterProfileDataCenter等,另外要要注意的是,幾個DataCenter之間最好不要有業務重疊。如果一個DataCenter的業務實在是大,那就再拆分成幾個小業務。如果單個小業務都很大了,那就拆成各個Category,具體的做法可以參考我的框架中CTPersistanceTableCTPersistanceQueryCommand的實踐。

這麼一來,如果要遷移涉及持久層的強業務,那就只需要遷移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];


在View層要做的事情其實到這裏就已經結束了,此時我們回過頭再來看DataCenter如何實現這個業務:


/* in DataCenter */

- (NSArray *)fetchItemListWithFilter:(NSDictionary *)filter
{
    ...
    ...
    ...

    /*
    解析filter獲得查詢所需要的數據
    whereCondition
    whereConditionParams
    假設上面這兩個變量就是解析得到的變量
    */

    ...
    ...
    ...

    /* 告知Table對象查詢數據後需要轉化成的對象(可選,統一返回對象可以便於歸併來自不同表的數據) */
    self.itemATable.recordClass = [Item class];
    self.itemBTable.recordClass = [Item class];
    self.itemCTable.recordClass = [Item class];

    /* 通過Table對象獲取數據,此時Table對象內執行的就是弱業務了 */
    NSArray *itemAList = [self.itemATable findAllWithWhereCondition:whereCondition conditionParams:whereConditionParams isDistinct:NO error:NULL];
    NSArray *itemBList = [self.itemBTable findAllWithWhereCondition:whereCondition conditionParams:whereConditionParams isDistinct:NO error:NULL];
    NSArray *itemCList = [self.itemCTable findAllWithWhereCondition:whereCondition conditionParams:whereConditionParams isDistinct:NO error:NULL];

    /* 組裝數據 */
    NSMutableArray *resultList = [[NSMutableArray alloc] init];
    [resultList addObjectsFromArray:itemAList];
    [resultList addObjectsFromArray:itemBList];
    [resultList addObjectsFromArray:itemCList];

    return resultList;
}


基本上差不多就是上面這樣的流程。

一般來說,架構師設計得差的持久層,都沒有通過設計DataCenter和Table,去將強業務和弱業務分開。通過設計DataCenter和Table對象,主要是便於代碼遷移。如果遷移強業務,把DataCenter和Table一起拿走就可以,如果只是遷移弱業務,拿走Table就可以了。


另外,通過代碼我希望向你強調一下這個概念:將Table和Record區分開,這個在我之前畫的架構圖上已經有所表現,不過上文並沒有着重強調。其實很多別的架構師在設計持久層框架的時候,也沒有將Table和Record區分開,對的,這裏我說的框架包括Core Data和FMDB,這個也不僅限於iOS領域,CodeIgniter、ThinkPHP、Yii、Flask這些也都沒有對這個做區分。(這裏吐槽一下,話說上文我還提到Core Data被過度設計了,事實上該設計的地方沒設計到,不該設計的地方各種設計往上堆...)


以上就是對Virtual Record這個設計的簡單介紹,接下來我們就開始討論不同場景下如何進行交互了。

其中我們最爲熟悉的一個場景是這樣的:經過各種邏輯組裝出一個數據對象,然後把這個數據對象交付給持久層去處理。這種場景我稱之爲一對一的交互場景,這個交互場景的實現非常傳統,就跟大家想得那樣,而且CTPersistance的test case裏面都是這樣的,所以這裏我就不多說了。所以,既然你已經知道有了一對一,那麼順理成章地就也會有多對一,以及一對多的交互場景。

下面我會一一描述Virtual Record是如何發揮虛擬的優勢去針對不同場景進行交互的。




多對一場景下,業務層如何與持久層交互?



多對一場景其實有兩種理解,一種是一個記錄的數據由多個View的數據組成。例如一張用戶表包含用戶的所有資料。然後有的View只包含用戶暱稱用戶頭像,有的對象只包含用戶ID用戶Token。然而這些數據都只存在一張用戶表中,所以這是一種多個對象的數據組成一個完整Record數據的場景,這是多對一場景的理解之一。

第二種理解是這樣的,例如一個ViewA對象包含了一個Record的所有信息,然後另一個ViewB對象其實也包含了一個Record的所有信息,這就是一種多個不同對象表達了一個Record數據的場景,這也是一種多對一場景的理解。

同時,這裏所謂的交互還分兩個方向:存和取。

其實這兩種理解的解決方案都是一樣的,Virtual Record的實現者通過實現Merge操作來完成record數據的彙總,從而實現存操作。任意Virtual Record的實現者通過Merge操作,就可以將自己的數據交付給其它不同的對象進行表達,從而實現取操作。具體的實現在下面有具體闡釋。




多對一場景下,如何進行存操作?



<CTPersistanceProtocol>提供了- (NSObject <CTPersistanceRecordProtocol> *)mergeRecord:(NSObject <CTPersistanceRecordProtocol> *)record shouldOverride:(BOOL)shouldOverride;這個方法。望文生義一下,就是一個record可以與另外一個record進行merge。在shouldOverride爲NO的情況下,任何一邊的nil都會被另外一邊不是nil的記錄覆蓋,如果merge過程中兩個對象都不含有這些空數據,則根據shouldOverride來決定是否要讓參數中record的數據覆蓋自己本身的數據,若shouldOverride爲YES,則即便是nil,也會把已有的值覆蓋掉。這個方法會返回被Merge的這個對象,便於鏈式調用。

舉一個代碼樣例:


/*
這裏的RecordViewA, RecordViewB, RecordViewC都是符合<CTPersistanceRecordProtocol>且實現了- (NSObject <CTPersistanceRecordProtocol> *)mergeRecord:(NSObject <CTPersistanceRecordProtocol> *)record shouldOverride:(BOOL)shouldOverride方法。
*/

RecordViewA *a;
RecordViewB *b;
RecordViewC *c;

...
收集a, b, c的值的邏輯,我就不寫了~
...

[[a mergeRecord:b shouldOverride:YES] mergeRecord:c shouldOverride:YES];
[self.dataCenter saveRecord:a];


基本思路就是通過merge不同的record對象來達到獲取完整數據的目的,由於是Virtual Record,具體的實現都是由各自的View去決定。View是最瞭解自己屬性的對象了,因此它是有充要條件來把自己與持久層相關的數據取出並Merge的,那麼這段湊數據的代碼,就相應分散到了各個View對象中,Controller裏面就能夠做到非常乾淨,整體可維護性也就提高了。

如果採用傳統方式,ViewController或者DataCenter中就會散落很多用於湊數據的代碼,寫的時候就會出現一大段用於合併的代碼,非常難看,還不容易維護。




多對一場景下,如何進行取操作?



其實這樣的表述並不恰當,因爲無論Virtual Record的實現如何,對象是誰,只要從數據庫裏面取出數據來,數據就都是能夠保證完整的。這裏更準確的表述是,取出數據之後,如何交付給不同的對象。其實還是用到上面提到的mergeRecord方法來處理。


/*
這裏的RecordViewA, RecordViewB, RecordViewC都是符合<CTPersistanceRecordProtocol>且實現了- (NSObject <CTPersistanceRecordProtocol> *)mergeRecord:(NSObject <CTPersistanceRecordProtocol> *)record shouldOverride:(BOOL)shouldOverride方法。
*/

RecordViewA *a;
RecordViewB *b = [[RecordViewB alloc] init];
RecordViewC *c = [[RecordViewC alloc] init];

a = [self.table findLatestRecordWithError:NULL];
[b mergeRecord:a];
[c mergeRecord:a];

return @[a, b, c]


這樣就能很容易把a記錄的數據交給b和c了,代碼觀感同樣非常棒,而且容易寫容易維護。




一對多場景下,業務層如何與持久層交互?



一對多場景也有兩種理解,其一是一個對象包含了多個表的數據,另外一個是一個對象用於展示多種表的數據,這個代碼樣例其實文章前面已經有過,這一節會着重強調一下。乍看之下兩者並沒有什麼區別,所以我需要指出的是,前者強調的是包含,也就是這個對象是個大熔爐,由多個表的數據組成。

還是舉用戶列表的例子:


假設數據庫中用戶相關的表有多張。大多數情況是因爲單表Column太多,所以爲了提高維護性和查詢性能而進行的縱切


多說一句,縱切在實際操作時,大多都是根據業務場景去切分成多個不同的表,分別來表達用戶各業務相關的部分數據,所以縱切的結果就是把Column特別多的一張表拆成Column不那麼多的好幾個表。雖然數據庫經過了縱切,但是有的場景還是要展示完整數據的,比如用戶詳情頁。因此,這個用戶詳情頁的View就有可能包含用戶基礎信息表(用戶名、用戶ID、用戶Token等)、以及用戶詳細信息表(用戶郵箱地址、用戶手機號等)。這就是一對多一個對象包含了多個表的數據的意思。

後者強調的是展示。舉個例子,數據庫中有三個表分別是:


二手房新房租房,它們三者的數據分別存儲在三個表裏面,這其實是一種橫切


橫切也是一種數據庫的優化手段,橫切與縱切不同的地方在於,橫切是在保留了這套數據的完整性的前提下進行的切分,橫切的結果就是把一個原本數據量很大的表,分成了好幾個數據量不那麼大的表。也就是原來三種房子都能用同一個表來存儲,但是這樣數據量就太大了,數據庫響應速度就會下降。所以根據房子的類型拆成這三張表。橫切也有根據ID切的,比如根據ID取餘的結果來決定分在哪些表裏,這種做法比較廣泛,因爲拓展起來方便,到時候數據表又大了,大不了除數也跟着再換一個更大的數罷了。其實根據類型去橫切也可以,只是拓展的時候就不那麼方便。


剛纔扯遠了現在我再扯回來,這三張表在展示的時候,只是根據類型的不同,界面纔有稍許不同而已,所以還是會用同一張View去展示這三種數據,這就是一對多一個對象用於展示多種表的數據的意思。




一個對象包含了多個表的數據時,如何進行存取操作?



在進行取操作時,其實跟前面多對一的取操作是一樣的,用Merge操作就可以了。


RecordViewA *a;

a = [self.CasaTable findLatestRecordWithError:NULL];
[a mergeRecord:[self.TaloyumTable findLatestRecordWithError:NULL] shouldOverride:YES];
[a mergeRecord:[self.CasatwyTable findLatestRecordWithError:NULL] shouldOverride:YES];

return a;


在進行存操作時,Virtual Record<CTPersistanceRecordProtocol>要求實現者實現- (NSDictionary *)dictionaryRepresentationWithColumnInfo:(NSDictionary *)columnInfo tableName:(NSString *)tableName;這個方法,實現者可以根據傳入的columnInfotableName返回相應的數據,這樣就能夠把這一次存數據時關心的內容提供給持久層了。代碼樣例就是這樣的:


RecordViewA *a = ...... ;

/*
由於有- (NSDictionary *)dictionaryRepresentationWithColumnInfo:(NSDictionary *)columnInfo tableName:(NSString *)tableName;的實現,a對象自己會提供給不同的Table它們感興趣的內容而存儲。

所以直接存就好了。
*/

[self.CasaTable insertRecord:a error:NULL];
[self.TaloyumTable insertRecord:a error:NULL];
[self.CasatwyTable insertRecord:a error:NULL];


通過上面的存取案例,你會發現使用Virtual Record之後,代碼量一下子少掉很多,原本那些亂七八糟用於拼湊條件的代碼全部被分散進了各個虛擬記錄的實現中去了,代碼維護因此就變得相當方便。若是採用傳統做法,再存取之前少不了要寫一大段邏輯,如果涉及代碼遷移,這大段邏輯就也得要跟着遷移過去,這就很蛋疼了。




一個對象用於展示多種表的數據,如何進行存取操作?



在這種情況下的存操作其實跟上面一樣,直接存。Virtual Record的實現者自己會根據要存入的表的信息組裝好數據提供給持久層。樣例代碼與上一小節的存操作中給出的一模一樣,我就不復制粘貼了。

取操作就不太一樣了,不過由於取出時的對象是唯一的(因爲一對多嘛),代碼也一樣十分簡單:


ViewRecord *a;
ViewRecord *b;
ViewRecord *c;

self.itemATable.recordClass = [ViewRecord class];
self.itemBTable.recordClass = [ViewRecord class];
self.itemCTable.recordClass = [ViewRecord class];

[a = self.itemATable findLatestRecordWithError:NULL];
[b = self.itemBTable findLatestRecordWithError:NULL];
[c = self.itemCTable findLatestRecordWithError:NULL];


這裏的abc都是同一個View,然後itemATableitemBTableitemCTable分別是不同種類的表。這個例子表示了一個對象如何用於展示不同類型的數據。如果使用傳統方法,這裏少不了要寫很多適配代碼,但是使用Virtual Record之後,這些代碼都由各自實現者消化掉了,在執行數據邏輯時可以無需關心適配邏輯。




多對多場景?



其實多對多場景就是上述這些一對多多對一場景的排列組合,實現方式都是一模一樣的,我這裏就也不多囉嗦了。




交互方案的總結



在交互方案的設計中,架構師應當區分好強弱業務,把傳統的Data Model區分成TableRecord,並由DataCenter去實現強業務,Table去實現弱業務。在這裏由於DataCenter是強業務相關,所以在實際編碼中,業務工程師負責創建DataCenter,並向業務層提供業務友好的方法,然後再在DataCenter中操作Table來完成業務層交付的需求。區分強弱業務,將TableRecord拆分開的好處在於:


  1. 通過業務細分降低耦合度,使得代碼遷移和維護非常方便
  2. 通過拆解數據處理邏輯和數據表達形態,使得代碼具有非常良好的可拓展性
  3. 做到讀寫隔離,避免業務層的誤操作引入Bug
  4. 爲Virtual Record這一設計思路的實踐提供基礎,進而實現更靈活,對業務更加友好的架構


任何不區分強弱業務的架構都是架構師在耍流氓,嗯。

在具體與業務層交互時,採用Virtual Record的設計思路來設計Record,由具體的業務對象來實現Virtual Record,並以它作爲DataCenter和業務層之間的數據媒介進行交互。而不是使用傳統的數據模型來與業務層做交互。

使用Virtual Record的好處在於:


  1. 將數據適配和數據轉化邏輯封裝到具體的Record實現中,可以使得代碼更加抽象簡潔,代碼污染更少
  2. 數據遷移時只需要遷移Virtual Record相關方法即可,非常容易拆分
  3. 業務工程師實現業務邏輯時,可以在不損失可維護性的前提下,極大提高業務實現的靈活性


這一部分還順便提了一下橫切縱切的概念。本來是打算有一小節專門寫數據庫性能優化的,不過事實上移動App場景下數據庫的性能優化手段不像服務端那樣豐富多彩,很多牛逼技術和參數調優手段想用也用不了。差不多就只剩下數據切片的手段比較有效了,所以性能優化這塊感覺沒什麼好寫的。其實大家瞭解了切片的方式和場景,就足以根據自己的業務場景去做優化了。再使用一下Instrument的Time Profile再配合SQLite提供的一些函數,就足以找到慢在哪兒,然後去做性能調優了。但如果我把這些也寫出來,就變成教你怎麼使用工具,感覺這個太low寫着也不起勁,大家有興趣搜使用手冊下來看就行。




數據庫版本遷移方案



一般來說,具有持久層的App同時都會附帶着有版本遷移的需求。當一個用戶安裝了舊版本的App,此時更新App之後,若數據庫的表結構需要更新,或者數據本身需要批量地進行更新,此時就需要有版本遷移機制來進行這些操作。然而版本遷移機制又要兼顧跨版本的遷移需求,所以基本上大方案也就只有一種:建立數據庫版本節點,遷移的時候一個一個跑過去。

數據遷移事實上實現起來還是比較簡單的,做好以下幾點問題就不大了:


  1. 根據應用的版本記錄每一版數據庫的改變,並將這些改變封裝成對象
  2. 記錄好當前數據庫的版本,便於跟遷移記錄做比對
  3. 在啓動數據庫時執行遷移操作,如果遷移失敗,提供一些降級方案


CTPersistance在數據遷移方面,凡是對於數據庫原本沒有的數據表,如果要新增,在使用table的時候就會自動創建。因此對於業務工程師來說,根本不需要額外多做什麼事情,直接用就可以了。把這部分工作放到這裏,也是爲數據庫版本遷移節省了一些步驟。


CTPersistance也提供了Migrator。業務工程師可以自己針對某一個數據庫編寫一個Migrator。這個Migrator務必派生自CTPersistanceMigrator,且符合<CTPersistanceMigratorProtocol>,只要提供一個migrationStep的字典,以及記錄版本順序的數組。然後把你自己派生的Migrator的類名和對應關心的數據庫名寫在CTPersistanceConfiguration.plist裏面就可以。CTPersistance會在初始數據庫的時候,根據plist裏面的配置對應找到Migrator,並執行數據庫版本遷移的邏輯。


在版本遷移時要注意的一點是性能問題。我們一般都不會在主線程做版本遷移的事情,這自然不必說。需要強調的是,SQLite本身是一個容錯性非常強的數據庫引擎,因此差不多在執行每一個SQL的時候,內部都是走的一個Transaction。當某一版的SQL數量特別多的時候,建議在版本遷移的方法裏面自己建立一個Transaction,然後把相關的SQL都包起來,這樣SQLite執行這些SQL的時候速度就會快一點。

其他的似乎並沒有什麼要額外強調的了,如果有沒說到的地方,大家可以在評論區提出來。




數據同步方案



數據同步方案大致分兩種類型,一種類型是單向數據同步,另一種類型是雙向數據同步。下面我會分別說說這兩種類型的數據同步方案的設計。




單向數據同步



單向數據同步就是隻把本地較新數據的操作同步到服務器,不會從服務器主動拉取同步操作。


比如即時通訊應用,一個設備在發出消息之後,需要等待服務器的返回去知道這個消息是否發送成功,是否取消成功,是否刪除成功。然後數據庫中記錄的數據就會隨着這些操作是否成功而改變狀態。但是如果換一臺設備繼續執行操作,在這個新設備上只會拉取舊的數據,比如聊天記錄這種。但對於舊的數據並沒有刪除或修改的需求,因此新設備也不會問服務器索取數據同步的操作,所以稱之爲單向數據同步。


單向數據同步一般來說也不需要有job去做定時更新的事情。如果一個操作遲遲沒有收到服務器的確認,那麼在應用這邊就可以認爲這個操作失敗,然後一般都是在界面上把這些失敗的操作展示出來,然後讓用戶去勾選需要重試的操作,然後再重新發起請求。微信在消息發送失敗的時候,就是消息前面有個紅色的圈圈,裏面有個感嘆號,只有用戶點擊這個感嘆號的時候才重新發送消息,背後不會有個job一直一直跑。


所以細化需求之後,我們發現單向數據同步只需要做到能夠同步數據的狀態即可。




如何完成單向數據同步的需求



添加identifier



添加identifier的目的主要是爲了解決客戶端數據的主鍵和服務端數據的主鍵不一致的問題。由於是單向數據同步,所以數據的生產者只會是當前設備,那麼identifier也理所應當由設備生成。當設備發起同步請求的時候,把identifier帶上,當服務器完成任務返回數據時,也把這些identifier帶上。然後客戶端再根據服務端給到的identifier再更新本地數據的狀態。identifier一般都會採用UUID字符串。




添加isDirty



isDirty主要是針對數據的插入和修改進行標識。當本地新生成數據或者更新數據之後,收到服務器的確認返回之前,isDirty置爲YES。當服務器的確認包返回之後,再根據包裏提供的identifier找到這條數據,然後置爲NO。這樣就完成了數據的同步。

然而這只是簡單的場景,有一種比較極端的情況在於,當請求發起到收到請求回覆的這短短几秒間,用戶又修改了數據。如果按照當前的邏輯,在收到請求回覆之後,這個又修改了的數據的isDirty會被置爲NO,於是這個新的修改就永遠無法同步到服務器了。這種極端情況的簡單處理方案就是在發起請求到收到回覆期間,界面上不允許用戶進行修改。

如果希望做得比較細緻,在發送同步請求期間依舊允許用戶修改的話,就需要在數據庫額外增加一張DirtyList來記錄這些操作,這個表裏至少要有兩個字段:identifierprimaryKey。然後每一次操作都分配一次identifier,那麼新的修改操作就有了新的identifier。在進行同步時,根據primaryKey找到原數據表裏的那條記錄,然後把數據連同identifier交給服務器。然後在服務器的確認包回來之後,就只要拿出identifier再把這條操作記錄刪掉即可。這個表也可以直接服務於多個表,只是還需要額外添加一個tablename字段,方便發起同步請求的時候能夠找得到數據。




添加isDeleted



當有數據同步的需求的時候,刪除操作就不能是簡單的物理刪除了,而只是邏輯刪除,所謂邏輯刪除就是在數據庫裏把這條記錄的isDeleted記爲YES,只有當服務器的確認包返回之後,纔會真正把這條記錄刪除。isDeleted和isDirty的區別在於:收到確認包後,返回的identifier指向的數據如果是isDeleted,那麼就要刪除這條數據,如果指向的數據只是新插入的數據和更新的數據,那麼就只要修改狀態就行。插入數據和更新數據在收到數據包之後做的操作是相同的,所以就用isDirty來區分就足夠了。總之,這是根據收到確認包之後的操作不同而做的區分。兩者都要有,缺一不可。




在請求的數據包中,添加dependencyIdentifier



在我看到的很多其它數據同步方案中,並沒有提供dependencyIdentifier,這會導致一個這樣的問題:假設有兩次數據同步請求一起發出,A先發,B後發。結果反而是B請求先到,A請求後到。如果A請求的一系列同步操作裏面包含了插入某個對象的操作,B請求的一系列同步操作裏面正好又刪除了這個對象,那麼由於到達次序的先後問題錯亂,就導致這個數據沒辦法刪除。

這個在移動設備的使用場景下是很容易發生的,移動設備本身網絡環境就多變,先發的包反而後到,這種情況出現的機率還是比較大的。所以在請求的數據包中,我們要帶上上一次請求時一系列identifier的其中一個,就可以了。一般都是選擇上次請求裏面最後的那一個操作的identifier,這樣就能表徵上一次請求的操作了。

服務端這邊也要記錄最近的100個請求包裏面的最後一個identifier。之所以是100條純屬只是拍腦袋定的數字,我覺得100條差不多就夠了,客戶端發請求的時候denpendency應該不會涉及到前面100個包。服務端在收到同步請求包的時候,先看denpendencyIdentifier是否已被記錄,如果已經被記錄了,那麼就執行這個包裏面的操作。如果沒有被記錄,那就先放着再等等,等到條件滿足了再執行,這樣就能解決這樣的問題。

之所以不用更新時間而是identifier來做標識,是因爲如果要用時間做標識的話,就是隻能以客戶端發出數據包時候的時間爲準。但有時不同設備的時間不一定完全對得上,多少會差個幾秒幾毫秒,另外如果同時有兩個設備發起同步請求,這兩個包的時間就都是一樣的了。假設A1, B1是1號設備發送的請求,A2, B2,是2號設備發送的請求,如果用時間去區分,A1到了之後,B2說不定就直接能夠執行了,而A1還沒到服務器呢。

當然,這也是一種極端情況,用時間的話,服務器就只要記錄一個時間了,凡是依賴時間大於這個時間的,就都要再等等,實現起來就比較方便。但是爲了保證bug儘可能少,我認爲依賴還是以identifier爲準,這要比以時間爲準更好,而且實現起來其實也並沒有增加太多複雜度。




單向數據同步方案總結



  1. 改造的時候添加identifier,isDirty,isDeleted字段。如果在請求期間依舊允許對數據做操作,那麼就要把identifier和primaryKey再放到一個新的表中
  2. 每次生成數據之後對應生成一個identifier,然後只要是針對數據的操作,就修改一次isDirty或isDeleted,然後發起請求帶上identifier和操作指令去告知服務器執行相關的操作。如果是複雜的同步方式,那麼每一次修改數據時就新生成一次identifier,然後再發起請求帶上相關數據告知服務器。
  3. 服務器根據請求包的identifier等數據執行操作,操作完畢回覆給客戶端確認
  4. 收到服務器的確認包之後,根據服務器給到的identifier(有的時候也會有tablename,取決於你的具體實現)找到對應的記錄,如果是刪除操作,直接把數據刪除就好。如果是插入和更新操作,就把isDirty置爲NO。如果有額外的表記錄了更新操作,直接把identifier對應的這個操作記錄刪掉就行。




要注意的點



在使用表去記錄更新操作的時候,短時間之內很有可能針對同一條數據進行多次更新操作。因此在同步之前,最好能夠合併這些相同數據的更新操作,可以節約服務器的計算資源。當然如果你服務器強大到不行,那就無所謂了。




雙向數據同步



雙向數據同步多見於筆記類、日程類應用。對於一臺設備來說,不光自己會往上推數據同步的信息,自己也會問服務器主動索取數據同步的信息,所以稱之爲雙向數據同步。

舉個例子:當一臺設備生成了某時間段的數據之後,到了另外一臺設備上,又修改了這些舊的歷史數據。此時再回到原來的設備上,這臺設備就需要主動問服務器索取是否舊的數據有修改,如果有,就要把這些操作下載下來同步到本地。

雙向數據同步實現上會比單向數據同步要複雜一些,而且有的時候還會存在實時同步的需求,比如協同編輯。由於本身方案就比較複雜,另外一定要兼顧業務工程師的上手難度(這主要看你這個架構師的良心),所以要實現雙向數據同步方案的話,還是很有意思比較有挑戰的。




如何完成雙向數據同步的需求



封裝操作對象



這個其實在單向數據同步時多少也涉及了一點,但是由於單向數據同步的要求並不複雜,只要告訴服務器是什麼數據然後要做什麼事情就可以了,倒是沒必要將這種操作封裝。在雙向數據同步時,你也得解析數據操作,所以互相之間要約定一個協議,通過封裝這個協議,就做到了針對操作對象的封裝。

這個協議應當包括:


  1. 操作的唯一標識
  2. 數據的唯一標識
  3. 操作的類型
  4. 具體的數據,主要是在Insert和Update的時候會用到
  5. 操作的依賴標識
  6. 用戶執行這項操作時的時間戳


分別解釋一下這6項的意義:




  1. 操作的唯一標識


這個跟單向同步方案時的作用一樣,也是在收到服務器的確認包之後,能夠使得本地應用找到對應的操作並執行確認處理。



  1. 數據的唯一標識


在找到具體操作的時候執行確認邏輯的處理時,都會涉及到對象本身的處理,更新也好刪除也好,都要在本地數據庫有所體現。所以這個標識就是用於找到對應數據的。



  1. 操作的類型


操作的類型就是DeleteUpdateInsert,對應不同的操作類型,對本地數據庫執行的操作也會不一樣,所以用它來進行標識。



  1. 具體的數據


當更新的時候有Update或者Insert操作的時候,就需要有具體的數據參與了。這裏的數據有的時候不見得是單條的數據內容,有的時候也會是批量的數據。比如把所有10月1日之前的任務都標記爲已完成狀態。因此這裏具體的數據如何表達,也需要定一個協議,什麼時候作爲單條數據的內容去執行插入或更新操作,什麼時候作爲批量的更新去操作,這個自己根據實際業務需求去定義就行。



  1. 操作的依賴標識


跟前面提到的依賴標識一樣,是爲了防止先發的包後到後發的包先到這種極端情況。



  1. 用戶執行這項操作的時間戳


由於跨設備,又因爲舊數據也會被更新,因此在一定程度上就會出現衝突的可能。操作數據在從服務器同步下來之後,會存放在一個新的表中,這個表就是待操作數據表,在具體執行這些操作的同時會跟待同步的數據表中的操作數據做比對。如果是針對同一條數據的操作,且這兩個操作存在衝突,那麼就以時間戳來決定如何執行。還有一種做法就是直接提交到界面告知用戶,讓用戶做決定。




新增待操作數據表和待同步數據表



前面已經部分提到這一點了。從服務器拉下來的同步操作列表,我們存在待執行數據表中,操作完畢之後如果有告知服務器的需求,那就等於是走單向同步方案告知服務器。在執行過程中,這些操作也要跟待同步數據表進行匹配,看有沒有衝突,沒有衝突就繼續執行,有衝突的話要麼按照時間戳執行,要麼就告知用戶讓用戶做決定。在拉取待執行操作列表的時候,也要把最後一次操作的identifier丟給服務器,這樣服務器才能返回相應數據。

待同步數據表的作用其實也跟單向同步方案時候的作用類似,就是防止在發送請求的時候用戶有操作,同時也是爲解決衝突提供方便。在發起同步請求之前,我們都應該先去查詢有沒有待執行的列表,當待執行的操作列表同步完成之後,就可以刪除裏面的記錄了,然後再把本地待同步的數據交給服務器。同步完成之後就可以把這些數據刪掉了。因此在正常情況下,只有在待操作待執行的操作間會存在衝突。有些從道理上講也算是衝突的事情,比如獲取待執行的數據比較晚,但其中又和待同步中的操作有衝突,像這種極端情況我們其實也無解,只能由他去,不過這種情況也是屬於比較極端的情況,發生機率不大。




何時從服務器拉取待執行列表



  1. 每次要把本地數據丟到服務器去同步之前,都要拉取一次待執行列表,執行完畢之後再上傳本地同步數據
  2. 每次進入相關頁面的時候都更新一次,看有沒有新的操作
  3. 對實時性要求比較高的,要麼客戶端本地起一個線程做輪詢,要麼服務器通過長鏈接將待執行操作推送過來
  4. 其它我暫時也想不到了,具體還是看需求吧




雙向數據同步方案總結



  1. 設計好同步協議,用於和服務端進行交互,以及指導本地去執行同步下來的操作
  2. 添加待執行待同步數據表記錄要執行的操作和要同步的操作




要注意的點



我也見過有的方案是直接把SQL丟出去進行同步的,我不建議這麼做。最好還是將操作和數據分開,然後細化,否則檢測衝突的時候你就得去分析SQL了。要是這種實現中有什麼bug,解這種bug的時候就要考慮前後兼容問題,機制重建成本等,因爲貪圖一時偷懶,到最後其實得不償失。




總結



這篇文章主要是基於CTPersistance講了一下如何設計持久層的設計方案,以及數據遷移方案和數據同步方案。

着重強調了一下各種持久層方案在設計時要考慮的隔離,以及提出了Virtual Record這個設計思路,並對它做了一些解釋。然後在數據遷移方案設計時要考慮的一些點。在數據同步方案這一節,分開講了單向的數據同步方案和雙向的數據同步方案的設計,然而具體實現還是要依照具體的業務需求來權衡。

希望大家覺得這些內容對各自工作中遇到的問題能夠有所價值,如果有問題,歡迎在評論區討論。

另外,關於動態部署方案,其實直到今天在iOS領域也並沒有特別好的動態部署方案可以拿出來,我覺得最靠譜的其實還是H5和Native的Hybrid方案。React Native在我看來相比於Hybrid還是有比較多的限制。關於Hybrid方案,我也提供了CTJSBridge這個庫去實現這方面的需求。在動態部署方案這邊其實成文已經很久,遲遲不發的原因還是因爲覺得當時並沒有什麼銀彈可以解決iOS App的動態部署,另外也有一些問題沒有考慮清楚。當初想到的那些問題現在我已經確認無解。當初寫的動態部署方案我一直認爲它無法作爲一個單獨的文章發佈出來,所以我就把這篇文章也放在這裏,權當給各位參考。








iOS動態部署方案



前言


這裏討論的動態部署方案,就是指通過不發版的方式,將新的內容、新的業務流程部署進已發佈的App。因爲蘋果的審覈週期比較長,而且蘋果的限制比較多,業界在這裏也沒有特別多的手段來達到動態部署方案的目的。這篇文章主要的目的就是給大家列舉一下目前業界做動態部署的手段,以及其對應的優缺點。然後給出一套我比較傾向於使用的方案。

其實單純就動態部署方案來講,沒什麼太多花頭可以說的,就是H5、Lua、JS、OC/Swift這幾門基本技術的各種組合排列。寫到後面覺得,動態部署方案其實是非常好的用於講解某些架構模式的背景。一般我們經驗總結下來的架構模式包括但不限於:


  1. Layered Architecture
  2. Event-Driven Architecture
  3. Microkernel Architecture
  4. Microservices Architecture
  5. Space-Based Architecture


我在開篇裏面提到的MVC等方案跟這篇文章中要提到的架構模式並不是屬於同一個維度的。比較容易混淆的就是容易把MVC這些方案跟Layered Architecture混淆,這個我在開篇這篇文章裏面也做過了區分:MVC等方案比較側重於數據流動方向的控制和數據流的管理。Layered Architecture更加側重於各分層之間的功能劃分和模塊協作。

另外,上述五種架構模式在Software Architecture Patterns這本書裏有非常詳細的介紹,整本書才45頁,個把小時就看完了,非常值得看和思考。本文後半篇涉及的架構模式是以上架構模式的其中兩種:Microkernel ArchitectureMicroservices Architecture

最後,文末還給出了其他一些關於架構模式的我覺得還不錯的PPT和論文,裏面對架構模式的分類和總結也比較多樣,跟Software Architecture Patterns的總結也有些許不一樣的地方,可以博採衆長。




Web App


實現方案


其實所謂的web app,就是通過手機上的瀏覽器進行訪問的H5頁面。這個H5頁面是針對移動場景特別優化的,比如UI交互等。



優點


  1. 無需走蘋果流程,所有蘋果流程帶來的成本都能避免,包括審覈週期、證書成本等。
  2. 版本更新跟網頁一樣,隨時生效。
  3. 不需要Native App工程師的參與,而且市面上已經有很多針對這種場景的框架。



缺點


  1. 由於每一頁都需要從服務器下載,因此web app重度依賴網絡環境。
  2. 同樣的UI效果使用web app來實現的話,流暢度不如Native,比較影響用戶體驗。
  3. 本地持久化的部分很難做好,繞過本地持久化的部分的辦法就是提供賬戶體系,對應賬戶的持久化數據全部存在服務端。
  4. 即時響應方案、遠程通知實現方案、移動端傳感器的使用方案複雜,維護難度大。
  5. 安全問題,H5頁面等於是所有東西都暴露給了用戶,如果對安全要求比較高的,很多額外的安全機制都需要在服務端實現。



總結

web app一般是創業初期會重點考慮的方案,因爲迭代非常快,而且創業初期的主要目標是需要驗證模式的正確性,並不在於提供非常好的用戶體驗,只需要完成閉環即可。早年facebook曾經嘗試過這種方案,最後因爲用戶體驗的問題而宣佈放棄。所以這個方案只能作爲過渡方案,或者當App不可用時,作爲降級方案使用。




Hybrid App


通過市面上各種Hybrid框架,來做H5和Native的混合應用,或者通過JS Bridge來做到H5和Native之間的數據互通。



優點


  1. 除了要承擔蘋果流程導致的成本以外,具備所有web app的優勢
  2. 能夠訪問本地數據、設備傳感器等



缺點


  1. 跟web app一樣存在過度依賴網絡環境的問題
  2. 用戶體驗也很難做到很好
  3. 安全性問題依舊存在
  4. 大規模的數據交互很難實現,例如圖片在本地處理後,將圖片傳遞給H5



總結

Hybrid方案更加適合跟本地資源交互不是很多,然後主要以內容展示爲主的App。在天貓App中,大量地採用了JS Bridge的方式來讓H5跟Native做交互,因爲天貓App是一個以內容展示爲主的App,且營銷活動多,週期短,比較適合Hybrid。




React-Native


嚴格來說,React-Native應當放到Hybrid那一節去講,單獨拎出來的原因是Facebook自從放出React-Native之後,業界討論得非常激烈。天貓的鬼道也做了非常多的關於React-Native的分享。

React-Native這個框架比較特殊,它展示View的方式依然是Native的View,然後也是可以通過URL的方式來動態生成View。而且,React-Native也提供了一個Bridge通道來做Javascript和Objective-C之間的交流,還是很貼心的。

然而研究了一下發現有一個比較坑的地方在於,解析JS要生成View時所需要的View,是要本地能夠提供的。舉個例子,比如你要有一個特定的Mapview,並且要響應對應的delegate方法,在React-Native的環境下,你需要先在Native提供這個Mapview,並且自己實現這些delegate方法,在實現完方法之後通過Bridge把數據回傳給JS端,然後重新渲染。

在這種情況下我們就能發現,其實React-Native在使用View的時候,這些View是要經過本地定製的,並且將相關方法通過RCT_EXPORT_METHOD暴露給js,js端才能正常使用。在我看來,這裏在一定程度上限制了動態部署時的靈活性,比如我們需要在某個點擊事件中展示一個動畫或者一個全新的view,由於本地沒有實現這個事件或沒有這個view,React-Native就顯得捉襟見肘。



優點


  1. 響應速度很快,只比Native慢一點,比webview快很多。
  2. 能夠做到一定程度上的動態部署



缺點


  1. 組裝頁面的元素需要Native提供支持,一定程度上限制了動態部署的靈活性。



總結


由於React-Native框架中,因爲View的展示和View的事件響應分屬於不同的端,展示部分的描述在JS端,響應事件的監聽和描述都在Native端,通過Native轉發給JS端。所以,從做動態部署的角度上講,React-Native只能動態部署新View,不能動態部署新View對應的事件。當然,React-Native本身提供了很多基礎組件,然而這個問題仍然還是會限制動態部署的靈活性。因爲我們在動態部署的時候,大部分情況下是希望View和事件響應一起改變的。

另外一個問題就在於,View的原型需要從Native中取,這個問題相較於上面一個問題倒是顯得不那麼嚴重,只是以後某個頁面需要添加某個複雜的view的時候,需要從現有的組件中拼裝罷了。

所以,React-Native事實上解決的是如何不使用Objc/Swift來寫iOS App的View的問題,對於如何通過不發版來給已發版的App更新功能這樣的問題,幫助有限。




Lua Patch


大衆點評的屠毅敏同學在基於wax的基礎上寫了waxPatch,這個工具的主要原理是通過lua來針對objc的方法進行替換,由於lua本身是解釋型語言,可以通過動態下載得到,因此具備了一定的動態部署能力。然而iOS系統原生並不提供lua的解釋庫,所以需要在打包時把lua的解釋庫編譯進app。



優點


  1. 能夠通過下載腳本替換方法的方式,修改本地App的行爲。
  2. 執行效率較高



缺點


  1. 對於替換功能來說,lua是很不錯的選擇。但如果要添加新內容,實際操作會很複雜
  2. 很容易改錯,小問題變成大問題



總結

lua的解決方案在一定程度上解決了動態部署的問題。實際操作時,一般不使用它來做新功能的動態部署,主要還是用於修復bug時代碼的動態部署。實際操作時需要注意的另外一點是,真的很容易改錯,尤其是你那個方法特別長的時候,所以改了之後要徹底迴歸測試一次。




Javascript Patch


這個工作原理其實跟上面說的lua那套方案的工作原理一樣,只不過是用javascript實現。而且最近新出了一個JSPatch這個庫,相當好用。



優點


  1. 同Lua方案的優點
  2. 打包時不用將解釋器也編譯進去,iOS自帶JavaScript的解釋器,只不過要從iOS7.0以後才支持。



缺點


  1. 同Lua方案的缺點



總結


在對app打補丁的方案中,目前我更傾向於使用JSPatch的方案,在能夠完成Lua做到的所有事情的同時,還不用編一個JS解釋器進去,而且會javascript的人比會lua的人多,技術儲備比較好做。




JSON Descripted View


其實這個方案的原理是這樣的:使用JSON來描述一個View應該有哪些元素,以及元素的位置,以及相關的屬性,比如背景色,圓角等等。然後本地有一個解釋器來把JSON描述的View生成出來。

這跟React-Native有點兒像,一個是JS轉Native,一個是JSON轉Native。但是同樣有的問題就是事件處理的問題,在事件處理上,React-Native做得相對更好。因爲JSON不能夠描述事件邏輯,所以JSON生成的View所需要的事件處理都必須要本地事先掛好。



優點


  1. 能夠自由生成View並動態部署



缺點


  1. 天貓實際使用下來,發現還是存在一定的性能問題,不夠快
  2. 事件需要本地事先寫好,無法動態部署事件



總結


其實JSON描述的View比React-Native的View有個好處就在於對於這個View而言,不需要本地也有一套對應的View,它可以依據JSON的描述來自己生成。然而對於事件的處理是它的硬傷,所以JSON描述View的方案,一般比較適用於換膚,或者固定事件不同樣式的View,比如貼紙。




架構模式


其實我們要做到動態部署,至少要滿足以下需求:

  1. View和事件都要能夠動態部署
  2. 功能完整
  3. 便於維護


我更加傾向於H5和Native以JSBridge的方式連接的方案進行動態部署,在cocoapods裏面也有蠻多的JSBridge了。看了一圈之後,我還是選擇寫了一個CTJSBridge,來滿足動態部署和後續維護的需求。關於這個JSBridge的使用中的任何問題和需求,都可以在評論區向我提出來。接下來的內容,會主要討論以下這些問題:

  1. 爲什麼不是React-Native或其它方案?
  2. 採用什麼樣的架構模式纔是使用JSBridge的最佳實踐?



爲什麼不是React-Native或其他方案?


首先針對React-Native來做解釋,前面已經分析到,React-Native有一個比較大的侷限在於View需要本地提供。假設有一個頁面的組件是跑馬燈,如果本地沒有對應的View,使用React-Native就顯得很麻煩。然而同樣的情況下,HTML5能夠很好地實現這樣的需求。這裏存在一個這樣的取捨在性能和動態部署View及事件之間,選擇哪一個?

我更加傾向於能夠動態部署View和事件,至少後者是能夠完成需求的,性能再好,難以完成需求其實沒什麼意義。然而對於HTML5的Hybrid和純HTML5的web app之間,也存在一個相同的取捨,但是還要額外考慮一個新的問題,純HTML5能夠使用到的設備提供的功能相對有限,JSBridge能夠將部分設備的功能以Native API的方式交付給頁面,因此在考慮這個問題之後,選擇HTML5的Hybrid方案就顯得理所應當了。

在諸多Hybrid方案中,除了JSBridge之外,其它的方案都顯得相對過於沉重,對於動態部署來說,其實需要補充的軟肋就是提供本地設備的功能,其它的反而顯得較爲累贅。



基於JSBridge的微服務架構模式


我開發了一個,基於JSBridge的微服務架構差不多是這樣的:


                                 -------------------------
                                 |                       |
                                 |         HTML5         |
                                 |                       |
                                 | View + Event Response |
                                 |                       |
                                 -------------------------
                                             |
                                             |
                                             |
                                          JSBridge
                                             |
                                             |
                                             |
        ------------------------------------------------------------------------------
        |                                                                            |
        |   Native                                                                   |
        |                                                                            |
        |  ------------   ------------   ------------   ------------   ------------  |
        |  |          |   |          |   |          |   |          |   |          |  |
        |  | Service1 |   | Service2 |   | Service3 |   | Service4 |   |    ...   |  |
        |  |          |   |          |   |          |   |          |   |          |  |
        |  ------------   ------------   ------------   ------------   ------------  |
        |                                                                            |
        |                                                                            |
        ------------------------------------------------------------------------------


解釋一下這種架構背後的思想:

因爲H5和Native之間能夠通過JSBridge進行交互,然而JSBridge的一個特徵是,只能H5主動發起調用。所以理所應當地,被調用者爲調用者提供服務。

另外一個想要處理的問題是,希望能夠通過微服務架構,來把H5和Native各自的問題域區分開。所謂區分問題域就是讓H5要解決的問題和Native要解決的問題之間,交集最小。因此,我們設計時希望H5的問題域能夠更加偏重業務,然後Native爲H5的業務提供基礎功能支持,例如API的跨域調用,傳感器設備信息以及本地已經沉澱的業務模塊都可以作爲Native提供的服務交給H5去使用。H5的快速部署特性特別適合做重業務的事情,Native對iPhone的功能調用能力和控制能力特別適合將其封裝成服務交給H5調用。

所以這對Native提供的服務有兩點要求:


  1. Native提供的服務不應當是強業務相關的,最好是跟業務無關,這樣才能方便H5進行業務的組裝
  2. 如果Native一定要提供強業務相關的服務,那最好是一個完整業務,這樣H5就能比較方便地調用業務模塊。


只要Native提供的服務符合上述兩個條件,HTML5在實現業務的時候,束縛就會非常少,也非常容易管理。




然後這種方案也會有一定的侷限性,就是如果Native沒有提供這樣的服務,那還是必須得靠發版來解決。等於就是Native向HTML5提供API,這其實跟服務端向Native提供API的道理一樣。

但基於Native提供的服務的通用性這點來看,添加服務的需求不會特別頻繁,每一個App都有屬於自己的業務領域,在同一個業務領域下,其實需要Native提供的服務是有限的。然後結合JSPatch提供的動態patch的能力,這樣的架構能夠滿足絕大部分動態部署的需求。

然後隨着App的不斷迭代,某些HTML5的實現其實是可以逐步沉澱爲Native實現的,這在一定程度上,降低了App早期的試錯成本。




基於動態庫的微內核模式


我開發了CTDynamicLibKit這個庫來解決動態庫的調用問題,其實原先的打算是拿動態庫做動態部署的,不過我用@念紀 的個人App把這個功能塞進去之後,發現蘋果還是能審覈通過的,但是下載下來的動態庫是無法加載的。報錯如下:


error:Error Domain=NSCocoaErrorDomain Code=3587 "The bundle “DynamicLibDemo” couldn’t be loaded because it is damaged or missing necessary resources." (dlopen_preflight(/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo): no suitable image found.  Did find:
        /var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo: code signature invalid for '/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo'
    ) UserInfo=0x174260b80 {NSLocalizedFailureReason=The bundle is damaged or missing necessary resources., NSLocalizedRecoverySuggestion=Try reinstalling the bundle., NSFilePath=/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo, NSDebugDescription=dlopen_preflight(/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo): no suitable image found.  Did find:
        /var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo: code signature invalid for '/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework/DynamicLibDemo'
    , NSBundlePath=/var/mobile/Containers/Data/Application/61D3BF00-CF8D-4157-A87C-D999905E9040/Library/DynamicLibDemo1.framework, NSLocalizedDescription=The bundle DynamicLibDemo couldnt be loaded because it is damaged or missing necessary resources.}


主要原因是因爲簽名無法通過。因爲Distribution的App只能加載相同證書打包的framework。在in house和develop模式下,可以使用相同證書既打包App又打包framework,所以測試的時候沒有問題。但是在正式的distribution下,這種做法是行不通的。

所以就目前看來,基於動態庫的動態部署方案是沒辦法做到的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章