- 原文地址:Offline support: 「Try again, later」, no more.
- 原文做者:Yonatan V. Levin
- 譯文出自:掘金翻譯計劃
- 譯者:skyar2009
- 校對者:phxnirvana, yazhi1992
我很榮幸生活在一個 4G 網絡和 Wifi 隨處可見的國家,家中、公司、甚至我朋友公寓的地下室(都有網絡)。
儘管如此,我依然會遇到下面的問題:javascript
或者html
或許是手機在和我開玩笑吧……java
網絡鏈接是我用過最不穩定的東西。95% 的狀況下網絡是正常工做的,我能流暢地欣賞喜歡的音樂,可是在電梯中發送消息則每每會失敗。android
像咱們程序員生存在良好的的網絡環境下這不是什麼問題,但事實上這是個問題。甚至會傷害你的用戶,尤爲是他們最須要你的 App 時(詳見墨菲定律)。git
做爲一個 Android 用戶,我注意到了在我安裝的許多應用中都存在『重試』的問題。我努力作些什麼改善這類問題,至少是在本身的應用中。程序員
關於離線支持有不少好的觀點,例如 Yigit Boyar 和他的 IO talk (你甚至能夠看到我在前排爲他點贊)。github
最終,當我開始創辦本身的公司 KolGene 以後,我有了機會。你們都知道,創業公司首先須要構建一個 MVP 來驗證假設的正確性。這個過程是如此的關鍵、艱難,任何一個環節均可能出錯,甚至由於未聯網問題而致使失去一個用戶也是沒法接受的。數據庫
每失去一個用戶都意味着咱們的許多支出打了水漂。
若是是由於應用使用體驗差而離開,那也是不能接受的。c#
咱們的應用使用很簡單:臨牀醫生在手機應用上建立基因測試的請求;相關實驗室將收到信息、提交試驗結果;臨牀醫生收到結果,並根據須要選擇最好的結果。服務器
通過一系列 UX 方案的討論,最終咱們決定使用以下方案:拋棄加載進度條 —— 儘管它很美麗。
應用應該流暢地運行,不須要置用戶於等待狀態。
總的來講咱們要實現的是讓網絡鏈接再也不是問題 —— 應用永遠可用。
結果以下:
當用戶處於離線模式,他只要提交請求就會成功。
僅有的離線狀態小提示是右上角的同步狀態圖標。一旦聯網,不管應用是在前臺仍是後臺,都會將用戶的請求發送到服務器。
除了註冊和登陸外的其餘網絡請求都採用了相同的處理。
咱們是如何實現的呢?
咱們首先完全地將視圖、邏輯以及持久化的模型分開。如 Yigit Boyar 所說:
本地操做,全局同步。
這就意味着你的模型須要持久化而且會被外界更新。模型中的數據應該使用回調/事件的方法異步地傳遞給 presenter 以及視圖。記住 —— 視圖是不能言語的,它只是對模型中內容的顯示。沒有加載對話框和任何內容。視圖響應用戶的操做,並經過 presenter 將交互結果傳遞到模型,而後接收、顯示下一狀態。
本地存儲咱們使用的是 SQLite。在它基礎上咱們包裝了一層 Content Provider,由於其對事件的 ContentObserver 能力。
ContentProvider 是對數據訪問和操做很是好的抽象。
爲何不使用 RxJava?呃,這是另外一個話題了。長話短說,做爲創業公司,咱們動做要儘量快而且項目幾個月就要迭代更新一次,因此咱們決定開發過程越簡單越好。
並且,我喜歡 ContentProvider,它還有一些額外的能力:自動初始化,單獨進程運行以及自定義搜索接口。
對於後臺同步任務,咱們選擇使用的是 GCMNetworkManager。 若是你對它不熟悉 —— 它支持在達到特定條件時觸發調度執行任務/週期性任務,好比網絡恢復鏈接,GCMNetworkManager 在 Doze 模式 下工做很好。
框架結構以下所示:
步驟 1: Presenter 建立新訂單並經過 ContentResolver 傳遞給 Content Provider 存儲。
public class NewOrderPresenter extends BasePresenter<NewOrderView> {
//...
private int insertOrder(Order order) {
//turn order to ContentValues object (used by SQL to insert values to Table)
ContentValues values = order.createLocalOrder(order);
//call resolver to insert data to the Order table
Uri uri = context.getContentResolver().insert(KolGeneContract.OrderEntry.CONTENT_URI, values);
//get Id for order.
if (uri != null) {
return order.getLocalId();
}
return -1;
}
//...
}複製代碼
步驟 2: Content Provider 將數據存儲到本地數據庫,並通知全部觀察者新建立了一個『待處理』狀態的訂單。
public class KolGeneProvider extends ContentProvider {
//...
@Nullable @Override public Uri insert(@NonNull Uri uri, ContentValues values) {
//open DB for write
final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
//match URI to action.
final int match = sUriMatcher.match(uri);
Uri returnUri;
switch (match) {
//case of creating order.
case ORDER:
long _id = db.insertWithOnConflict(KolGeneContract.OrderEntry.TABLE_NAME, null, values,
SQLiteDatabase.CONFLICT_REPLACE);
if (_id > 0) {
returnUri = KolGeneContract.OrderEntry.buildOrderUriWithId(_id);
} else {
throw new android.database.SQLException(
"Failed to insert row into " + uri + " id=" + _id);
}
break;
default:
throw new UnsupportedOperationException("Unknown uri: " + uri);
}
//notify observables about the change
getContext().getContentResolver().notifyChange(uri, null);
return returnUri;
}
//...
}複製代碼
步驟 3: 咱們註冊的用來監聽訂單表的後臺服務,接收到相應 URI 並開始執行該任務的特定服務。
public class BackgroundService extends Service {
@Override public int onStartCommand(Intent intent, int i, int i1) {
if (observer == null) {
observer = new OrdersObserver(new Handler());
getContext().getContentResolver()
.registerContentObserver(KolGeneContract.OrderEntry.CONTENT_URI, true, observer);
}
}
//...
@Override public void handleMessage(Message msg) {
super.handleMessage(msg);
Order order = (Order) msg.obj;
Intent intent = new Intent(context, SendOrderService.class);
intent.putExtra(SendOrderService.ORDER_ID, order.getLocalId());
context.startService(intent);
}
//...
}複製代碼
步驟 4: 服務從 DB 獲取數據,並嘗試同步服務端。當網絡請求成功後,經過 ContentResolver 將訂單的狀態更新爲『已同步』。
public class SendOrderService extends IntentService {
@Override protected void onHandleIntent(Intent intent) {
int orderId = intent.getIntExtra(ORDER_ID, 0);
if (orderId == 0 || orderId == -1) {
return;
}
Cursor c = null;
try {
c = getContentResolver().query(
KolGeneContract.OrderEntry.buildOrderUriWithIdAndStatus(orderId, Order.NOT_SYNCED), null,
null, null, null);
if (c == null) return;
Order order = new Order();
if (c.moveToFirst()) {
order.getSelfFromCursor(c, order);
} else {
return;
}
OrderCreate orderCreate = order.createPostOrder(order);
List<LocationId> locationIds = new LabLocation().getLocationIds(this, order.getLocalId());
orderCreate.setLabLocations(locationIds);
Response<Order> response = orderApi.createOrder(orderCreate).execute();
if (response.isSuccessful()) {
if (response.code() == 201) {
Order responseOrder = response.body();
responseOrder.setLocalId(orderId);
responseOrder.setSync(Order.SYNCED);
ContentValues values = responseOrder.getContentValues(responseOrder);
Uri uri = getContentResolver().update(
KolGeneContract.OrderEntry.buildOrderUriWithId(order.getLocalId()), values);
return;
}
} else {
if (response.code() == 401) {
ClientUtils.broadcastUnAuthorizedIntent(this);
return;
}
}
} catch (IOException e) {
} finally {
if (c != null && !c.isClosed()) {
c.close();
}
}
SyncOrderService.scheduleOrderSending(getApplicationContext(), orderId);
}
}複製代碼
步驟 5: 若是請求失敗,會使用 GCMNetworkManager 安排一個一次性任務,設置 .setRequiredNetwork(Task.NETWORK_STATE_CONNECTED)
和訂單 id。
當條件達到時(設備鏈接網絡而且非 doze 模式),GCMNetworkManager 調用 onRunTask(),應用會再次嘗試同步訂單。若是依然失敗,從新進行調度。
public class SyncOrderService extends GcmTaskService {
//...
public static void scheduleOrderSending(Context context, int id) {
GcmNetworkManager manager = GcmNetworkManager.getInstance(context);
Bundle bundle = new Bundle();
bundle.putInt(SyncOrderService.ORDER_ID, id);
OneoffTask task = new OneoffTask.Builder().setService(SyncOrderService.class)
.setTag(SyncOrderService.getTaskTag(id))
.setExecutionWindow(0L, 30L)
.setExtras(bundle)
.setPersisted(true)
.setRequiredNetwork(Task.NETWORK_STATE_CONNECTED)
.build();
manager.schedule(task);
}
//...
@Override public int onRunTask(TaskParams taskParams) {
int id = taskParams.getExtras().getInt(ORDER_ID);
if (id == 0) {
return GcmNetworkManager.RESULT_FAILURE;
}
Cursor c = null;
try {
c = getContentResolver().query(
KolGeneContract.OrderEntry.buildOrderUriWithIdAndStatus(id, Order.NOT_SYNCED), null, null,
null, null);
if (c == null) return GcmNetworkManager.RESULT_FAILURE;
Order order = new Order();
if (c.moveToFirst()) {
order.getSelfFromCursor(c, order);
} else {
return GcmNetworkManager.RESULT_FAILURE;
}
OrderCreate orderCreate = order.createPostOrder(order);
List<LocationId> locationIds = new LabLocation().getLocationIds(this, order.getLocalId());
orderCreate.setLabLocations(locationIds);
Response<Order> response = orderApi.createOrder(orderCreate).execute();
if (response.isSuccessful()) {
if (response.code() == 201) {
Order responseOrder = response.body();
responseOrder.setLocalId(id);
responseOrder.setSync(Order.SYNCED);
ContentValues values = responseOrder.getContentValues(responseOrder);
Uri uri = getContentResolver().update(
KolGeneContract.OrderEntry.buildOrderUriWithId(order.getLocalId()), values);
return GcmNetworkManager.RESULT_SUCCESS;
}
} else {
if (response.code() == 401) {
ClientUtils.broadcastUnAuthorizedIntent(getApplicationContext());
}
}
} catch (IOException e) {
} finally {
if (c != null && !c.isClosed()) c.close();
}
return GcmNetworkManager.RESULT_RESCHEDULE;
}
//...
}複製代碼
訂單一旦同步成功,後臺服務或 GCMNetworkManager 會經過 ContentResolver 將訂單的本地狀態更新爲『已同步』。
固然該框架不是萬能的。你須要處理全部可能的邊界條件,例如同步一個服務端已經存在訂單,可是管理員已經在服務端對其進行了取消/修改?若是他們修改了相同的屬性怎麼辦?若是首次更新是由普通用戶或管理員進行會發生什麼?在咱們的產品中對部分這類問題已經處理,可是部分問題採起不處理方案(畢竟不多發生)。咱們解決這類問題的不一樣方法,我會在後面的文章進行介紹。
正如 Fred 所說,咱們的代碼庫確實存在改進空間:
即便最好的方案也不會完美到一次成功。
—— Fred Brooks
可是咱們會繼續爲改進而努力,讓咱們的 KolGene 使用起來更舒心,給用戶帶來知足。