- 原文地址:Offline support: “Try again, later”, no more.
- 原文作者:Yonatan V. Levin
- 譯文出自:掘金翻譯計劃
- 譯者:skyar2009
- 校對者:phxnirvana, yazhi1992
離線支援:不再『稍後重試』
我很榮幸生活在一個 4G 網路和 Wifi 隨處可見的國家,家中、公司、甚至我朋友公寓的地下室(都有網路)。
儘管如此,我依然會遇到下面的問題:
或者
或許是手機在和我開玩笑吧……
網路連線是我用過最不穩定的東西。95% 的情況下網路是正常工作的,我能流暢地欣賞喜歡的音樂,但是在電梯中傳送訊息則往往會失敗。
像我們程式設計師生存在良好的的網路環境下這不是什麼問題,但事實上這是個問題。甚至會傷害你的使用者,尤其是他們最需要你的 App 時(詳見墨菲定律)。
作為一個 Android 使用者,我注意到了在我安裝的許多應用中都存在『重試』的問題。我努力做些什麼改善這類問題,至少是在自己的應用中。
關於離線支援有很多好的觀點,例如 Yigit Boyar 和他的 IO talk (你甚至可以看到我在前排為他點贊)。
我們的寶貝應用
最終,當我開始創辦自己的公司 KolGene 之後,我有了機會。大家都知道,創業公司首先需要構建一個 MVP 來驗證假設的正確性。這個過程是如此的關鍵、艱難,任何一個環節都可能出錯,甚至因為未聯網問題而導致失去一個使用者也是無法接受的。
每失去一個使用者都意味著我們的許多支出打了水漂。
如果是因為應用使用體驗差而離開,那也是不能接受的。
我們的應用使用很簡單:臨床醫生在手機應用上建立基因測試的請求;相關實驗室將收到資訊、提交試驗結果;臨床醫生收到結果,並根據需要選擇最好的結果。
經過一系列 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 使用起來更舒心,給使用者帶來滿足。