[譯] 離線支持:再也不『稍後重試』


離線支持:再也不『稍後重試』

我很榮幸生活在一個 4G 網絡和 Wifi 隨處可見的國家,家中、公司、甚至我朋友公寓的地下室(都有網絡)。
儘管如此,我依然會遇到下面的問題:javascript

或者html

或許是手機在和我開玩笑吧……java

網絡鏈接是我用過最不穩定的東西。95% 的狀況下網絡是正常工做的,我能流暢地欣賞喜歡的音樂,可是在電梯中發送消息則每每會失敗。android

像咱們程序員生存在良好的的網絡環境下這不是什麼問題,但事實上這是個問題。甚至會傷害你的用戶,尤爲是他們最須要你的 App 時(詳見墨菲定律)。git

做爲一個 Android 用戶,我注意到了在我安裝的許多應用中都存在『重試』的問題。我努力作些什麼改善這類問題,至少是在本身的應用中。程序員

關於離線支持有不少好的觀點,例如 Yigit Boyar 和他的 IO talk (你甚至能夠看到我在前排爲他點贊)。github


咱們的寶貝應用

最終,當我開始創辦本身的公司 KolGene 以後,我有了機會。你們都知道,創業公司首先須要構建一個 MVP 來驗證假設的正確性。這個過程是如此的關鍵、艱難,任何一個環節均可能出錯,甚至由於未聯網問題而致使失去一個用戶也是沒法接受的。數據庫

每失去一個用戶都意味着咱們的許多支出打了水漂。
若是是由於應用使用體驗差而離開,那也是不能接受的。c#

咱們的應用使用很簡單:臨牀醫生在手機應用上建立基因測試的請求;相關實驗室將收到信息、提交試驗結果;臨牀醫生收到結果,並根據須要選擇最好的結果。服務器

通過一系列 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 使用起來更舒心,給用戶帶來知足。

相關文章
相關標籤/搜索