良好的離線體驗,讓你的應用不再 Try again, later

簡評:簡而言之,核心思想就是 Google I/O 2016 上 Yigit Boyar 的分享:Act locally, sync globally.

雖然現在 4G 和 wifi 不斷普及,但網路狀況不穩定的情況還是會出現,如果對這種情況設計的不好是會有損用戶體驗的。特別是對創業公司,每個用戶都來之不易,如果因為這樣的細節問題而失去了用戶,是不可接受的。

作者是一家初創公司的聯合創始人和 CTO,在文中就介紹了他們的應用是如何應對離線狀況下使用的問題。

作者的應用需求很簡單:客戶通過 App 創建基因測試的訂單,相應的實驗室收到消息,根據訂單信息決定是否接受訂單。

他們在討論 UX 時,決定不在應用中使用任何進度條,即使可以做到很漂亮。整個 App 用起來應該很順滑,不會讓用戶處於等待狀態。當用戶處於離線狀態,他提交了訂單...顯示成功了。當重新處於在線狀態後,應用便將請求發送到伺服器,無論現在應用是否在前台。那麼他們是怎麼做的呢?

首先,應用採用了 MVP 架構:

本地資料庫使用 SQLite,向上使用 Content Provider 來控制數據訪問,後台數據同步功能則使用了 GCMNetworkManager。所以整個架構是這樣的:

具體流程:

Step 1

Presenter 創建一個新的訂單並通過 ContentResolver 發送給 Content Provider。

public class NewOrderPresenter extends BasePresenter<NewOrderView> {n //...n n private int insertOrder(Order order) {n //turn order to ContentValues object (used by SQL to insert values to Table)n ContentValues values = order.createLocalOrder(order);n //call resolver to insert data to the Order tablen Uri uri = context.getContentResolver().insert(KolGeneContract.OrderEntry.CONTENT_URI, values);n //get Id for order.n if (uri != null) {n return order.getLocalId();n }n return -1;n }n n //...n}n

Step 2

Content Provider 將這條新訂單添加到本地資料庫並通知所有的「觀察者」有了一條新訂單,狀態是 pending。

public class KolGeneProvider extends ContentProvider {n //...n @Nullable @Override public Uri insert(@NonNull Uri uri, ContentValues values) {n //open DB for writen final SQLiteDatabase db = mOpenHelper.getWritableDatabase();n //match URI to action.n final int match = sUriMatcher.match(uri);n Uri returnUri;n switch (match) {n //case of creating order.n case ORDER:n long _id = db.insertWithOnConflict(KolGeneContract.OrderEntry.TABLE_NAME, null, values,n SQLiteDatabase.CONFLICT_REPLACE);n if (_id > 0) {n returnUri = KolGeneContract.OrderEntry.buildOrderUriWithId(_id);n } else {n throw new android.database.SQLException(n "Failed to insert row into " + uri + " id=" + _id);n }n break;n default:n throw new UnsupportedOperationException("Unknown uri: " + uri);n }n n //notify observables about the changen getContext().getContentResolver().notifyChange(uri, null);n return returnUri;n }n //...n}n

Step 3

後台服務監聽到訂單數據的變化並啟動特定的服務。

public class BackgroundService extends Service {nn @Override public int onStartCommand(Intent intent, int i, int i1) {n if (observer == null) {n observer = new OrdersObserver(new Handler());n getContext().getContentResolver()n .registerContentObserver(KolGeneContract.OrderEntry.CONTENT_URI, true, observer);n }n }n n n //...n @Override public void handleMessage(Message msg) {n super.handleMessage(msg);n Order order = (Order) msg.obj;n Intent intent = new Intent(context, SendOrderService.class);n intent.putExtra(SendOrderService.ORDER_ID, order.getLocalId());n context.startService(intent);n }n n //...nn}n

Step 4

Service 從 DB 獲取數據並通過網路進行同步。如果返回 success,通過 ContentResolver 將訂單狀態更新為 synced。

public class SendOrderService extends IntentService {nn @Override protected void onHandleIntent(Intent intent) {n int orderId = intent.getIntExtra(ORDER_ID, 0);n if (orderId == 0 || orderId == -1) {n return;n }nn Cursor c = null;n try {n c = getContentResolver().query(n KolGeneContract.OrderEntry.buildOrderUriWithIdAndStatus(orderId, Order.NOT_SYNCED), null,n null, null, null);n if (c == null) return;n Order order = new Order();n if (c.moveToFirst()) {n order.getSelfFromCursor(c, order);n } else {n return;n }nn OrderCreate orderCreate = order.createPostOrder(order);nn List<LocationId> locationIds = new LabLocation().getLocationIds(this, order.getLocalId());n orderCreate.setLabLocations(locationIds);n Response<Order> response = orderApi.createOrder(orderCreate).execute();nn if (response.isSuccessful()) {n if (response.code() == 201) {n Order responseOrder = response.body();n responseOrder.setLocalId(orderId);n responseOrder.setSync(Order.SYNCED);n ContentValues values = responseOrder.getContentValues(responseOrder);n Uri uri = getContentResolver().update(n KolGeneContract.OrderEntry.buildOrderUriWithId(order.getLocalId()), values);n return;n }n } else {n if (response.code() == 401) {n ClientUtils.broadcastUnAuthorizedIntent(this);n return;n }n }n } catch (IOException e) {n } finally {n if (c != null && !c.isClosed()) {n c.close();n }n }n SyncOrderService.scheduleOrderSending(getApplicationContext(), orderId);n }n}n

Step 5

如果網路請求返回 fail,就通過 GCMNetworkManager 設置一次條件任務,當條件滿足時(設備連接上網路並且沒有處在 doze mode),調用 onRunTask() 方法再一次同步數據。

public class SyncOrderService extends GcmTaskService {n //...n public static void scheduleOrderSending(Context context, int id) {n GcmNetworkManager manager = GcmNetworkManager.getInstance(context);n Bundle bundle = new Bundle();n bundle.putInt(SyncOrderService.ORDER_ID, id);n OneoffTask task = new OneoffTask.Builder().setService(SyncOrderService.class)n .setTag(SyncOrderService.getTaskTag(id))n .setExecutionWindow(0L, 30L)n .setExtras(bundle)n .setPersisted(true)n .setRequiredNetwork(Task.NETWORK_STATE_CONNECTED)n .build();n manager.schedule(task);n }n n //...n @Override public int onRunTask(TaskParams taskParams) {n int id = taskParams.getExtras().getInt(ORDER_ID);n if (id == 0) {n return GcmNetworkManager.RESULT_FAILURE;n }n Cursor c = null;n try {n c = getContentResolver().query(n KolGeneContract.OrderEntry.buildOrderUriWithIdAndStatus(id, Order.NOT_SYNCED), null, null,n null, null);n if (c == null) return GcmNetworkManager.RESULT_FAILURE;n Order order = new Order();n if (c.moveToFirst()) {n order.getSelfFromCursor(c, order);n } else {n return GcmNetworkManager.RESULT_FAILURE;n }nn OrderCreate orderCreate = order.createPostOrder(order);nn List<LocationId> locationIds = new LabLocation().getLocationIds(this, order.getLocalId());n orderCreate.setLabLocations(locationIds);n n Response<Order> response = orderApi.createOrder(orderCreate).execute();nn if (response.isSuccessful()) {n if (response.code() == 201) {n Order responseOrder = response.body();n responseOrder.setLocalId(id);n responseOrder.setSync(Order.SYNCED);n ContentValues values = responseOrder.getContentValues(responseOrder);n Uri uri = getContentResolver().update(n KolGeneContract.OrderEntry.buildOrderUriWithId(order.getLocalId()), values);n return GcmNetworkManager.RESULT_SUCCESS;n }n } else {n if (response.code() == 401) {n ClientUtils.broadcastUnAuthorizedIntent(getApplicationContext());n }n }n } catch (IOException e) {n } finally {n if (c != null && !c.isClosed()) c.close();n }n return GcmNetworkManager.RESULT_RESCHEDULE;n }nn //...n}n

當同步成功後,後台服務或 GCMNetworkManager 就會通過 ContentResolver 更新本地數據狀態為 synced。

當然這種方案也不是完美的,也有很多問題需要解決。作者會在接下來的文章中進一步的分享他們遇到的一些具體問題和解決辦法。

對於國內開發者來說 GCMNetworkManager 是用不了的,並且各種第三方 rom 錯綜複雜。可以考慮用 AlarmManager,只是會複雜些,性能等方面也不如 GCMNetworkManager。

原文:Offline support: 「Try again, later」, no more.

擴展閱讀:

  • 創造優秀的 Android 應用離線體驗

歡迎關註:知乎專欄「極光日報」,每天為 Makers 導讀三篇優質英文文章。


推薦閱讀:

TAG:Android开发 | Android | 编程 |