原文作者:Yonatan V. Levin
原文鏈接:https://medium.com/@yonatanvlevin/offline-support-try-again-later-no-more-afc33eba79dc
文章翻譯只用作知識(shí)分享。
翻譯時(shí)省略了一些內(nèi)容,如果翻譯有誤請(qǐng)大家糾正。
我有幸生活在一個(gè)遍布著4G網(wǎng)絡(luò)和WIFI的國(guó)家。在家,在公司,甚至是在我朋友公寓的浴室里。但是不知為何, 我仍然遇到

或者是

也許是因?yàn)槲业腜ixel phone和我開(kāi)了一個(gè)玩笑。哦..天哪
因特網(wǎng)是我曾經(jīng)使用過(guò)的最不穩(wěn)定的東西。95%的時(shí)間它是正常工作的,我可以流暢的播放我的喜歡的音樂(lè)沒(méi)有任何問(wèn)題,但是當(dāng)我站在電梯里嘗試發(fā)一個(gè)消息時(shí),出問(wèn)題了.
開(kāi)發(fā)者生活在一個(gè)網(wǎng)絡(luò)連接十分強(qiáng)大的環(huán)境中往往認(rèn)為它不是問(wèn)題,但事實(shí)上它是一個(gè)問(wèn)題。更多的時(shí)候,就像墨菲定律那樣,當(dāng)用戶期望你的程序運(yùn)行的很快,甚至更快的時(shí)候,這種情況會(huì)傷害用戶。
作為一個(gè)Android的用戶發(fā)現(xiàn)許多安裝在我手機(jī)上的程序都會(huì)提示請(qǐng)稍后再試時(shí)。我想努力為這種情況做點(diǎn)什么,至少在我做的引用程序上。
有很多關(guān)于離線如何工作的話題,例如Yigit Boyar和他的 IO talk
**作者做的APP **

在創(chuàng)業(yè)公司,所有人都知道要做一個(gè)最小的可行性產(chǎn)品(Minimum viable product 百度了一下)嘗試你的想法。這個(gè)過(guò)程是至關(guān)重要而又十分艱難的。有太多的原因可能失敗,因?yàn)殡x線而失去一個(gè)用戶是絕對(duì)無(wú)法接受的。用戶因?yàn)槲覀兊膽?yīng)用體驗(yàn)不好而離開(kāi)不能成為一個(gè)因素。
作者的APP用途很簡(jiǎn)單,臨床醫(yī)生發(fā)起一個(gè)請(qǐng)求,相關(guān)實(shí)驗(yàn)室收到請(qǐng)求報(bào)價(jià),臨床醫(yī)生從所有的報(bào)價(jià)中選則一個(gè)。
當(dāng)我們討論用戶體驗(yàn)時(shí)(UX),我們的決定如下:不需要任何的加載效果。應(yīng)用應(yīng)該能夠順利的運(yùn)行,不應(yīng)把用戶放在一個(gè)等待的狀態(tài)中。要達(dá)成的最基本目標(biāo)是:網(wǎng)絡(luò)不好的情況下,APP仍然能夠正常工作。

當(dāng)用戶在離線的情況下,它提交的請(qǐng)求仍然可以成功。僅僅有一個(gè)很小的圖標(biāo)---同步圖標(biāo),表面用戶在離線狀態(tài)。當(dāng)它的網(wǎng)絡(luò)正常的時(shí)候,APP將把請(qǐng)求發(fā)送出去,不論APP是在前臺(tái)還是后臺(tái)。對(duì)于每一個(gè)網(wǎng)絡(luò)請(qǐng)求都是如此,除了登錄和注冊(cè)以外。

那么我們是如何做到這一點(diǎn)的呢:
首先要做的就是將頁(yè)面,邏輯和持久層進(jìn)行分離。
這意味著你的數(shù)據(jù)將異步的方式通過(guò)回調(diào)/Events的方式傳遞給Presenter層再傳遞到視圖層。視圖層只負(fù)責(zé)和用戶交互,將交互的結(jié)果傳遞給其他模塊,并接收模塊的反應(yīng)呈現(xiàn)出另一種狀體。

1.本地存儲(chǔ)我們使用SQLite,在它之上我們決定通過(guò)ContentProvider封裝一下。因?yàn)樗?ContentObserver功能。ContentProvider對(duì)數(shù)據(jù)的訪問(wèn)和數(shù)據(jù)的操作做了很好的抽象。至于為什么不使用RxJava封裝作者也給出了意見(jiàn)。
2.對(duì)于后臺(tái)同步的任務(wù),我們選擇使用 GCMNetworkManager,它能夠在滿足一些確切的條件時(shí)執(zhí)行指定的任務(wù),比如說(shuō)網(wǎng)絡(luò)連接恢復(fù),它對(duì)低電量的模式也有很好的支持。所以項(xiàng)目結(jié)構(gòu)圖如下

步驟流程
1.創(chuàng)建訂單并同步
業(yè)務(wù)層創(chuàng)建一個(gè)訂單傳遞給ContentProvider并保存起來(lái)。

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.ContentProvider通知所有的觀察者有一個(gè)新的數(shù)據(jù)接入,數(shù)據(jù)狀態(tài)是有待操作的
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.后臺(tái)服務(wù)接收到通知后交給特定的服務(wù)去執(zhí)行
public class BackgroundService extends Service {
//注冊(cè)了一個(gè)監(jiān)聽(tīng),監(jiān)聽(tīng)數(shù)據(jù)的改變
@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);
//當(dāng)數(shù)據(jù)改變時(shí)通知SendOrderService去執(zhí)行
Order order = (Order) msg.obj;
Intent intent = new Intent(context, SendOrderService.class);
intent.putExtra(SendOrderService.ORDER_ID, order.getLocalId());
context.startService(intent);
}
//...
}
**4.服務(wù)從數(shù)據(jù)庫(kù)獲取到數(shù)據(jù)嘗試在網(wǎng)絡(luò)環(huán)境下同步它。更新訂單的狀態(tài)到同步完成通過(guò)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);
//嘗試通過(guò)網(wǎng)絡(luò)去更新訂單狀態(tài)
Response<Order> response = orderApi.createOrder(orderCreate).execute();
if (response.isSuccessful()) {
if (response.code() == 201) {
//成功時(shí)更新訂單狀態(tài)到 同步完成
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 {
//失敗時(shí)
if (response.code() == 401) {
ClientUtils.broadcastUnAuthorizedIntent(this);
return;
}
}
} catch (IOException e) {
} finally {
if (c != null && !c.isClosed()) {
c.close();
}
}
SyncOrderService.scheduleOrderSending(getApplicationContext(), orderId);
}
}
5.當(dāng)請(qǐng)求失敗時(shí),將在滿足.setRequiredNetwork(Task.NETWORK_STATE_CONNECTED) 條件時(shí)通過(guò)GCMNetworkManager執(zhí)行一次任務(wù),滿足標(biāo)準(zhǔn)的時(shí)候 GCMNetworkManager講執(zhí)行onRunTask()回調(diào),APP將嘗試再次同步訂單,如果再次失敗了,將改期再執(zhí)行
使用GCM 需要引入一些庫(kù),但是在國(guó)內(nèi)的支持不是很好,可以考慮使用AlarmManger
dependencies { compile 'com.google.android.gms:play-services-gcm:8.1.0' }

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;
}
//...
}

當(dāng)同步成功以后,將通過(guò)ContentResolve去更新數(shù)據(jù)。
當(dāng)然這樣的架構(gòu)并不是完善的,你需要考慮許多臨屆的情況。比如你更新了一個(gè)在服務(wù)端已經(jīng)存在的訂單,但是已經(jīng)在服務(wù)端修改或者刪除了訂單。兩端同時(shí)修改了一張訂單怎么辦。等等問(wèn)題將在作者的下一篇文章提出。
I have the privilege of living in a country 我有幸生活在一個(gè)國(guó)家? priv(?)lij
I have ever used 我曾經(jīng)使用過(guò)的
It will hurt your users exactly when they most need your App to work 當(dāng)恰好用戶需要你的程序工作會(huì)傷害到用戶
I struggled to do something about it 我努力為它做點(diǎn)什么。
In startups 在創(chuàng)業(yè)公司
as most of you know 所有人都知道
testing your assumptions 嘗試你的猜想
The process is so crucial and hard 這個(gè)過(guò)程是重要和困難的
totally unacceptable 絕對(duì)無(wú)法接受的
If there were leaving because the experience of using the application was bad?—?well, it’s not even an option
When we discussed various UX solutions 當(dāng)我們討論各種用戶體驗(yàn)時(shí)。
we decided on the following:我們的決定如下
So basically what we want to achieve:要達(dá)成的最基本目標(biāo)是
certain specific conditions met 滿足具體的條件
The service obtains the data from DB and tries to sync it over the network.服務(wù)從數(shù)據(jù)庫(kù)獲取到數(shù)據(jù)嘗試在網(wǎng)絡(luò)環(huán)境下同步它。
the order is updated with status “synced” via ContentResolver 更新訂單的狀態(tài)到同步完成通過(guò)ContentResolver
When the criteria is met 當(dāng)滿足標(biāo)準(zhǔn)的時(shí)候
The different approaches that we took to solve these isseus 解決這些問(wèn)題的不同方式