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

skyar2009發表於2019-02-17

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

我很榮幸生活在一個 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 使用起來更舒心,給使用者帶來滿足。

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

相關文章