本文讲的是[译] 离线支持:不再『稍后重试』,
- 原文地址: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 使用起来更舒心,给用户带来满足。
原文发布时间为:2017年3月24日
本文来自合作伙伴掘金,了解相关信息可以关注掘金网站。