Offline first 的設計最近有越來越多的感覺 (我不敢說是趨勢),但好的 Offline first 設計要解決蠻多的問題,是否使用 offline first 設計真的需要好好思考,不然可能得不到好處,反而還引起一堆 bug,本篇先探討在 client 端可能會遇到的問題與一些可能的解法,下篇再討論對 server 設計的影響。
Offline first (或稱 local first) 設計並不是這幾年才開始,早期的版本控管系統其實便是一種 offline first 的設計,只是系統不會自動幫開發者同步,且當衝突發生時,開發者得自己解決衝突,但開發者不需要持續維持連線狀態才能夠開發程式,有人可能不覺得這算是 offline first 的系統,那再來看個更不像的系統,線上遊戲。
對,很多人會覺得為什麼線上遊戲是 offline first?主要是要維持遊戲的畫面更新率,假設畫面更新率為 60 fps (平均 16.7 ms 更新一次畫面),當玩家對畫面中的怪物發動攻擊,玩家能接受多久畫面才顯示扣多少血?因此扣多少血會是由 client 呼叫 API 後才決定嗎?假設 API response time 是 200 ms,那畫面已經更新了 12 次了,對動態很講究的玩家可能會覺得卡頓。因此,不少 (不敢說全部) 效果都是由 client 先計算好,事後再跟 server 同步,讓整個體驗更好。
從線上遊戲的例子就可以知道,Offline first 並不是一定要支援完全離線,而是在網路不穩定或是短暫離線的情況下,您的應用程式仍然可以進行某些操作,這就能算是 offline first 的設計,如同Android 開發文件《Build an offline-first app》一開始的定義:
An offline-first app is an app that is able to perform all, or a critical subset of its core functionality without access to the internet. That is, it can perform some or all of its business logic offline.
一般來說,會想加入 offline first 的設計,大多是想提升使用者無違和感的流暢使用體驗,例如,大家能想像在 LINE 或是通訊軟體打完字按下送出,然後畫面出現個轉圈圈無法使用的情況嗎?或是一定要等前一則訊息送出後才能送出第二則訊息?又或是在網路不佳的環境,App 進到背景再回到前景,訊息就不見的情況?這都會讓使用者的體驗變得很不好。
要做到 offline first 需要蠻多要件的,每個要件都有一些眉角,其中比較重要的有幾個,後面一一討論:
Offline first 第一要件是需要有 local data source,即便是網路不佳的情況下,還是要有資料能提供部分的功能,事實上,這也是 mobile app 和 web app 最大的不同,假如真的沒網路,web app 往往就是白畫面,甚麼也無法操作,但 多數的 mobile app 不會白畫面,只是畫面的某些地方是沒有資料的,或是顯示舊資料,從這就可以看出本地資料還分成「冷資料或預載資料」以及「熱資料或動態資料」。
冷資料或預載資料,最簡單理解的方式便是網路遊戲的素材資料了,這方面的資料都很大,動則數百 MB 甚至幾 GB,通常都是讓使用者在下載 app 時就先直接下載到本地了,開始後使用者便不需要網路可直接進行遊戲,但即便是冷資料也是有更新的一天,這邊的冷資料是相對於熱資料,熱資料通常是時時變化,像是傳送的訊息、會員資料等,冷資料可能是十幾天或數月更動一次。
因此在設計時,便需要針對不同的變動頻率設計適合的更新方式,這年代可能很多人沒安裝過防毒軟體了,防毒軟體在安裝時會有一個基本的病毒碼庫,即使沒有網路,防毒軟體仍可以靠這個病毒碼庫針對已知的病毒進行掃描,但防毒軟體通常會定期每周 (或每天) 檢查是否有新版的病毒碼,然後下載,通常這類型的設計會是以 delta 的方式更新,而不是重新下載整包的病毒碼庫。
過去在水果公司開發的 app 就有類似的機制,當時 app 的動態貼圖並不是 GIF 這樣的動畫貼圖,而是用遊戲引擎以玩家的寵物即時繪製的動畫,玩家可以替寵物裝扮不同的服裝、造型、配件,甚至還有節日專屬的裝飾,因此每個玩家的動態貼圖都是與眾不同的。
當初為了這些設計了一整套的 pipeline,從設計師設計動畫及素材、CI/CD 從原始檔輸出成 app 能讀取的檔案、更新 metadata 到 app 能自動更新下載,下圖只是當初討論的簡略版,實際上還要更複雜,像是 metadata 格式該如何設計也是討論了很久。app 會透過這些 metadata 知道有那些檔案有改版,有哪些檔案是新增加的,下載時會根據是否有請求使用,有不同的優先程度進行下載。
甚至還加上了一些小巧思,假設玩家 A 收到玩家 B 的動態貼圖,但某個素材還沒下載到玩家 A 的手機裡,我們會有個紙箱的暫代素材,等到素材下載完畢後,再切換至剛下載的素材,於是寵物上會有紙箱就成了一個有趣的彩蛋。
後來離開水果公司,在和朋友創業時為關島觀光局做了個導覽 app,我們也將這概念加進去,下載完 app,裡面已經含有多國語言的導覽內容,這些內容是關島的業者到我們設計的 CMS 後台輸入的,只是我們會打包部分內容 (影片太大了就沒有包進去) 的最新版到 app 中,遊客在關島即使沒有網路也能使用導覽 app,不過若有網路,就會到 server 自動更新內容。
剛討論完冷資料,那熱資料呢?熱資料一般都是由使用者產生,相較之下,冷資料是由廠商提供,因此,熱資料在處理時和冷資料有幾個不同點:
版本的問題會在下一節討論,這邊先討論如何上傳與推送更新。
前述的 Android 開發文件中有提到同步的設計,但... 不知道為什麼,有時候我不是很喜歡 Android 官方的建議,例如,在官方文件中,由 AuthorRepository
控制存取的來源,乍看之下好像很合理,但這也很容易導致 server API 的設計變成遠端資料庫的操作,而不是針對 use case 設計。
如果沒有很小心地使用建議的設計,就有可能變成這樣,呼叫 RoomManager
的 invite
邀請好友加入聊天室,RoomManager
修改 room 內部的資料,然後呼叫 RoomRepository
的 update
,先呼叫 LocalRoomSource
更新本地端的資料庫,接著呼叫 RemoteRoomSource
同步變更,這樣的情況很容易就把 API 設計成 PUT /rooms/:roomId
,然後整個 room 資料丟給 server。問題是,邀請好友這件事,server 端真的只有把 client 丟上來的資料儲存起來就結束了?
事實上並不是,邀請好友加入聊天室,可能還會發通知給該好友,可能發訊息給聊天室中的其他人,因此,API 的設計不適合設計成只更新 room 資料,個人偏好 POST /rooms/:roomId/friends
。而 client 端則會是 RoomManager
修改 room 資料,呼叫 RoomRepository
的 update
,接著呼叫 RoomWebService
的 invite
邀請好友。Figure 3 和 Figure 4 看起來好像沒差很多,但 method signature 卻不同,意圖也不同。
當然,這是因為我的 Repository 設計是試著接近 PoEEA 書中的 intent (參閱閒談軟體設計:Repository),若直接將invite
設計成Repository
的一個 method,是不是就沒這問題呢?留給大家思考。
上述二個不同的設計思維,其實會影響到如何同步資料,第一種方式會比較像是把物件視作一個檔案,先存到本地端後,網路正常後將最新版上傳到 server。第二種方式,比較無法使用同步檔案的設計,通常會是將 requests 存放到 queue 之中,待網路恢復後,按順序呼叫,成功呼叫後從 queue 移除。
視作檔案的同步方式,優點是簡單且減少傳輸次數,例如,餐廳服務員發現某一張桌子的客人已經用完餐準備離開,於是先幫一筆候位指定桌號,等到其他服務生整理完桌子後帶位,於是將候位的狀態改成入座。以第一種方式同步,只需要該筆候位的最終狀態上傳,但第二種方式卻要呼叫兩次的 API 才能讓 server 端同步到跟 client 一樣的狀態。至於哪種好,這就要看系統是否要保留完整的歷程記錄了,這留到下一篇再討論。
用 request 的方式,呼叫順序是很重要的一件事,例如,餐廳將一筆 6 人的訂位,人數改成 4,但後來發現改錯了,又改成 5 人,假設執行的順序錯了,那訂位的人數也會是錯的。但依序執行可能會引起阻塞,導致某些可以很快就能完成的 request 被尚未完成的長 request 擋在後面,這時可以實作特殊的 queue,將會互相影響的 request 排到同個 queue 中,不相關的 request 則排到不同的 queue 中,增加平行處理的能力。
同樣是水果公司的 app,當初在寵物系統支援 offline first 時,便將每一筆交易變成一則 commnad,每次更換道具也是一則 command,client 先套用變更並儲存在本地端,command 則是慢慢送上 server 端執行,當然,牽扯到交易就會有一些問題要處理,假設使用者刻意用兩個 device 同時進行交易,當點數不足時該怎麼處理?這就要思考衝突排除原則了。
一旦資料上傳到 server 端了,如何讓其他 client 知道有更新需要同步呢?我想這邊很多人已經有答案了,早期的作法,像是用 client 端用 long polling 來取得更新,最近的作法則是 WebSocket,server 可以透過一個持續的連線,推送更新給 client。
說真的,傳統的線上遊戲 (區域網路連線對戰),早就是透過持續的 socket 連線進行溝通,WebSocket 只是讓網頁或是 mobile app 可以簡單建立連線,而不是使用 OS 層級的 socket。
不管是用 long polling 或是 WebSocket,當資料同步到 client 後,要如何讓程式知道該更新畫面了呢?這不正是 Observer pattern 可以發揮的地方!
不知道玩線上遊戲的人有沒有發現一個現象,有時候發動攻擊招式打某個怪,同時,另一個玩家也攻擊同一個怪,畫面上也回饋您的招式有打中對方,但最後經驗值卻是歸另一個玩家,這便是所謂的衝突排除。
團隊在討論 client 端的衝突排除原則時,要有二個共識:永遠要以 server 作為最終的資料來源,以及一定要有可以辨識版本的資訊。因為永遠以 server 作為最終的資料來源,衝突的排除主要是 server 端的工作,但怎麼上傳資料確實會影響 server 的設計,這也是我個人比較偏好 request/command 式的同步,能提供較多的 context 讓 server 決斷。
有了上述二個共識基本上 client 有幾種選擇:忠實回朔或是有條件無視。以剛剛的寵物系統為例,server 會根據 command 的請求、server 上的狀態,以及衝突排除原則做出最終的判斷,並將最終的狀態回給 client,此時 client 就將本地的狀態更新為 server 回傳的狀態。
假設,玩家有 150 點的點數,先在 client A 兌換並裝飾需 100 點的飾品 X,同時在 client B 兌換並裝飾 120 點的飾品 Y,等於使用了 220 點超額的點數,最終 server 根據原則判斷玩家取得了飾品 Y,並將結果回給 client A 與 client B,對 client B 而言,什麼事也沒發生,繼續裝飾式品 Y,但 client A 就會把飾品 X 移除並改裝飾飾品 Y,於是玩家就會在 client A 上看到類似回朔的現象,這和一開始的經驗值的例子類似。
在有資訊能判斷版本的情況下,client 是可以根據一些條件無視 server 回傳的結果,但這真的要非常小心,不然可能會變成 client 一直處在錯誤的狀態,又無視 server 回傳的狀態。通常,client 可以在已知自己還有狀態未同步給 server 的情況下,先無視 server 回傳的狀態。
以剛剛候位的為例,假設指定桌號是 request 1,帶入座是 request 2,client 端該筆候位已是有桌號且入座的狀態,當 request 1 送達 server 時,server 確認沒問題也將 server 端的候位資料加上桌號,並通知 client 最新的狀態,收到通知的 client 若選擇忠實回朔,已入座的候位突然會變成未入座,等到 request 2 送達 server 並被 server 接受後,才會又變回已入座的狀態。如果 client 因為 request 2 還未送達,所以選擇忽略,等到 request 2 送達後,server 與 client 的狀態就會是同步的,整體上會少一次回朔的現象。體驗上比較好,但實作上卻不太容易。
雖然 offline first 可以讓應用程式有更好的使用者體驗,更少的轉圈圈,不用等到有網路才能編輯資料,但要付出的成本是很高的,雖然有些 framework 或是雲端服務號稱能簡化 offline first 的開發,例如 Firebase realtime database,但上述的眉角,還是需要思考,不然 bug 會不少,特別是衝突排除原則,這就讓我們下回再繼續討論吧!