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

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

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

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

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

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

首先,應用采用了 MVP 架構(gòu):

本地數(shù)據(jù)庫使用 SQLite,向上使用 Content Provider 來控制數(shù)據(jù)訪問,后臺數(shù)據(jù)同步功能則使用了 GCMNetworkManager。所以整個架構(gòu)是這樣的:

具體流程:

Step 1

Presenter 創(chuàng)建一個新的訂單并通過 ContentResolver 發(fā)送給 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;
  }
  
  //...
}

Step 2

Content Provider 將這條新訂單添加到本地數(shù)據(jù)庫并通知所有的「觀察者」有了一條新訂單,狀態(tài)是 pending。


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

Step 3

后臺服務監(jiān)聽到訂單數(shù)據(jù)的變化并啟動特定的服務。


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

}

Step 4

Service 從 DB 獲取數(shù)據(jù)并通過網(wǎng)絡進行同步。如果返回 success,通過 ContentResolver 將訂單狀態(tài)更新為 synced。


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);
  }
}

Step 5

如果網(wǎng)絡請求返回 fail,就通過 GCMNetworkManager 設置一次條件任務,當條件滿足時(設備連接上網(wǎng)絡并且沒有處在 doze mode),調(diào)用 onRunTask() 方法再一次同步數(shù)據(jù)。


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 更新本地數(shù)據(jù)狀態(tài)為 synced。

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

對于國內(nèi)開發(fā)者來說 GCMNetworkManager 是用不了的,并且各種第三方 rom 錯綜復雜。可以考慮用 AlarmManager,只是會復雜些,性能等方面也不如 GCMNetworkManager。

歡迎關注

  • 知乎專欄「極光日報」,每天為 Makers 導讀三篇優(yōu)質(zhì)英文文章。
  • 網(wǎng)易云電臺「極光日報**」,上下班路上為你讀報。
最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容