離線時不用再說:請稍后再試

原文作者:Yonatan V. Levin
原文鏈接:https://medium.com/@yonatanvlevin/offline-support-try-again-later-no-more-afc33eba79dc
文章翻譯只用作知識分享故爵。
翻譯時省略了一些內(nèi)容,如果翻譯有誤請大家糾正诗越。

我有幸生活在一個遍布著4G網(wǎng)絡(luò)和WIFI的國家叔壤。在家,在公司匆篓,甚至是在我朋友公寓的浴室里惨奕。但是不知為何火架, 我仍然遇到

image.png

或者是

image.png

也許是因?yàn)槲业腜ixel phone和我開了一個玩笑诡延。哦..天哪
因特網(wǎng)是我曾經(jīng)使用過的最不穩(wěn)定的東西滞欠。95%的時間它是正常工作的,我可以流暢的播放我的喜歡的音樂沒有任何問題肆良,但是當(dāng)我站在電梯里嘗試發(fā)一個消息時筛璧,出問題了.
開發(fā)者生活在一個網(wǎng)絡(luò)連接十分強(qiáng)大的環(huán)境中往往認(rèn)為它不是問題,但事實(shí)上它是一個問題惹恃。更多的時候隧哮,就像墨菲定律那樣,當(dāng)用戶期望你的程序運(yùn)行的很快座舍,甚至更快的時候,這種情況會傷害用戶陨帆。
作為一個Android的用戶發(fā)現(xiàn)許多安裝在我手機(jī)上的程序都會提示請稍后再試時曲秉。我想努力為這種情況做點(diǎn)什么,至少在我做的引用程序上疲牵。
有很多關(guān)于離線如何工作的話題承二,例如Yigit Boyar和他的 IO talk

**作者做的APP **

image.png

在創(chuàng)業(yè)公司,所有人都知道要做一個最小的可行性產(chǎn)品(Minimum viable product 百度了一下)嘗試你的想法纲爸。這個過程是至關(guān)重要而又十分艱難的亥鸠。有太多的原因可能失敗,因?yàn)殡x線而失去一個用戶是絕對無法接受的。用戶因?yàn)槲覀兊膽?yīng)用體驗(yàn)不好而離開不能成為一個因素负蚊。

作者的APP用途很簡單神妹,臨床醫(yī)生發(fā)起一個請求,相關(guān)實(shí)驗(yàn)室收到請求報(bào)價(jià)家妆,臨床醫(yī)生從所有的報(bào)價(jià)中選則一個鸵荠。

當(dāng)我們討論用戶體驗(yàn)時(UX),我們的決定如下:不需要任何的加載效果伤极。應(yīng)用應(yīng)該能夠順利的運(yùn)行蛹找,不應(yīng)把用戶放在一個等待的狀態(tài)中。要達(dá)成的最基本目標(biāo)是:網(wǎng)絡(luò)不好的情況下哨坪,APP仍然能夠正常工作庸疾。

image.png

當(dāng)用戶在離線的情況下,它提交的請求仍然可以成功当编。僅僅有一個很小的圖標(biāo)---同步圖標(biāo)届慈,表面用戶在離線狀態(tài)。當(dāng)它的網(wǎng)絡(luò)正常的時候凌箕,APP將把請求發(fā)送出去拧篮,不論APP是在前臺還是后臺。對于每一個網(wǎng)絡(luò)請求都是如此牵舱,除了登錄和注冊以外串绩。

image.png

那么我們是如何做到這一點(diǎn)的呢:

首先要做的就是將頁面,邏輯和持久層進(jìn)行分離芜壁。

這意味著你的數(shù)據(jù)將異步的方式通過回調(diào)/Events的方式傳遞給Presenter層再傳遞到視圖層礁凡。視圖層只負(fù)責(zé)和用戶交互,將交互的結(jié)果傳遞給其他模塊慧妄,并接收模塊的反應(yīng)呈現(xiàn)出另一種狀體顷牌。

image.png

1.本地存儲我們使用SQLite,在它之上我們決定通過ContentProvider封裝一下塞淹。因?yàn)樗?ContentObserver功能窟蓝。ContentProvider對數(shù)據(jù)的訪問和數(shù)據(jù)的操作做了很好的抽象。至于為什么不使用RxJava封裝作者也給出了意見饱普。

2.對于后臺同步的任務(wù)运挫,我們選擇使用 GCMNetworkManager,它能夠在滿足一些確切的條件時執(zhí)行指定的任務(wù)套耕,比如說網(wǎng)絡(luò)連接恢復(fù)谁帕,它對低電量的模式也有很好的支持。所以項(xiàng)目結(jié)構(gòu)圖如下

image.png

步驟流程

1.創(chuàng)建訂單并同步
業(yè)務(wù)層創(chuàng)建一個訂單傳遞給ContentProvider并保存起來冯袍。

image.png

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.ContentProvider通知所有的觀察者有一個新的數(shù)據(jù)接入,數(shù)據(jù)狀態(tài)是有待操作的

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.后臺服務(wù)接收到通知后交給特定的服務(wù)去執(zhí)行

public class BackgroundService extends Service {
  //注冊了一個監(jiān)聽匈挖,監(jiān)聽數(shù)據(jù)的改變
  @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);
     //當(dāng)數(shù)據(jù)改變時通知SendOrderService去執(zhí)行
      Order order = (Order) msg.obj;
      Intent intent = new Intent(context, SendOrderService.class);
      intent.putExtra(SendOrderService.ORDER_ID, order.getLocalId());
      context.startService(intent);
  }
  
  //...
}

**4.服務(wù)從數(shù)據(jù)庫獲取到數(shù)據(jù)嘗試在網(wǎng)絡(luò)環(huán)境下同步它碾牌。更新訂單的狀態(tài)到同步完成通過ContentResolver **


image.png
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);
     //嘗試通過網(wǎng)絡(luò)去更新訂單狀態(tài)
      Response<Order> response = orderApi.createOrder(orderCreate).execute();

      if (response.isSuccessful()) {
        if (response.code() == 201) {
         //成功時更新訂單狀態(tài)到 同步完成
          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.當(dāng)請求失敗時,將在滿足.setRequiredNetwork(Task.NETWORK_STATE_CONNECTED) 條件時通過GCMNetworkManager執(zhí)行一次任務(wù)儡循,滿足標(biāo)準(zhǔn)的時候 GCMNetworkManager講執(zhí)行onRunTask()回調(diào)舶吗,APP將嘗試再次同步訂單,如果再次失敗了贮折,將改期再執(zhí)行

使用GCM 需要引入一些庫裤翩,但是在國內(nèi)的支持不是很好,可以考慮使用AlarmManger

dependencies {  compile 'com.google.android.gms:play-services-gcm:8.1.0' }
image.png
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;
  }

  //...
}
image.png

當(dāng)同步成功以后调榄,將通過ContentResolve去更新數(shù)據(jù)踊赠。

當(dāng)然這樣的架構(gòu)并不是完善的,你需要考慮許多臨屆的情況每庆。比如你更新了一個在服務(wù)端已經(jīng)存在的訂單筐带,但是已經(jīng)在服務(wù)端修改或者刪除了訂單。兩端同時修改了一張訂單怎么辦缤灵。等等問題將在作者的下一篇文章提出伦籍。

I have the privilege of living in a country 我有幸生活在一個國家? priv(?)lij
I have ever used 我曾經(jīng)使用過的
It will hurt your users exactly when they most need your App to work 當(dāng)恰好用戶需要你的程序工作會傷害到用戶
I struggled to do something about it 我努力為它做點(diǎn)什么。
In startups 在創(chuàng)業(yè)公司
as most of you know 所有人都知道
testing your assumptions 嘗試你的猜想
The process is so crucial and hard 這個過程是重要和困難的
totally unacceptable 絕對無法接受的
If there were leaving because the experience of using the application was bad?—?well, it’s not even an option
When we discussed various UX solutions 當(dāng)我們討論各種用戶體驗(yàn)時腮出。
we decided on the following:我們的決定如下
So basically what we want to achieve:要達(dá)成的最基本目標(biāo)是
certain specific conditions met 滿足具體的條件
The service obtains the data from DB and tries to sync it over the network.服務(wù)從數(shù)據(jù)庫獲取到數(shù)據(jù)嘗試在網(wǎng)絡(luò)環(huán)境下同步它帖鸦。
the order is updated with status “synced” via ContentResolver 更新訂單的狀態(tài)到同步完成通過ContentResolver
When the criteria is met 當(dāng)滿足標(biāo)準(zhǔn)的時候
The different approaches that we took to solve these isseus 解決這些問題的不同方式

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市胚嘲,隨后出現(xiàn)的幾起案子作儿,更是在濱河造成了極大的恐慌,老刑警劉巖馋劈,帶你破解...
    沈念sama閱讀 206,214評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件攻锰,死亡現(xiàn)場離奇詭異,居然都是意外死亡妓雾,警方通過查閱死者的電腦和手機(jī)娶吞,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,307評論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來械姻,“玉大人妒蛇,你說我怎么就攤上這事】” “怎么了材部?”我有些...
    開封第一講書人閱讀 152,543評論 0 341
  • 文/不壞的土叔 我叫張陵,是天一觀的道長唯竹。 經(jīng)常有香客問我,道長苦丁,這世上最難降的妖魔是什么浸颓? 我笑而不...
    開封第一講書人閱讀 55,221評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上产上,老公的妹妹穿的比我還像新娘棵磷。我一直安慰自己,他們只是感情好晋涣,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,224評論 5 371
  • 文/花漫 我一把揭開白布仪媒。 她就那樣靜靜地躺著,像睡著了一般谢鹊。 火紅的嫁衣襯著肌膚如雪算吩。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,007評論 1 284
  • 那天佃扼,我揣著相機(jī)與錄音偎巢,去河邊找鬼。 笑死兼耀,一個胖子當(dāng)著我的面吹牛压昼,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播瘤运,決...
    沈念sama閱讀 38,313評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼窍霞,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了拯坟?” 一聲冷哼從身側(cè)響起但金,我...
    開封第一講書人閱讀 36,956評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎似谁,沒想到半個月后傲绣,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,441評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡巩踏,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,925評論 2 323
  • 正文 我和宋清朗相戀三年秃诵,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片塞琼。...
    茶點(diǎn)故事閱讀 38,018評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡菠净,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出彪杉,到底是詐尸還是另有隱情毅往,我是刑警寧澤,帶...
    沈念sama閱讀 33,685評論 4 322
  • 正文 年R本政府宣布派近,位于F島的核電站攀唯,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏渴丸。R本人自食惡果不足惜侯嘀,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,234評論 3 307
  • 文/蒙蒙 一另凌、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧戒幔,春花似錦吠谢、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,240評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至敢订,卻和暖如春王污,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背枢析。 一陣腳步聲響...
    開封第一講書人閱讀 31,464評論 1 261
  • 我被黑心中介騙來泰國打工玉掸, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人醒叁。 一個月前我還...
    沈念sama閱讀 45,467評論 2 352
  • 正文 我出身青樓司浪,卻偏偏與公主長得像,于是被迫代替她去往敵國和親把沼。 傳聞我的和親對象是個殘疾皇子啊易,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,762評論 2 345

推薦閱讀更多精彩內(nèi)容