离线时不用再说:请稍后再试

原文作者:Yonatan V. Levin
原文链接:https://medium.com/@yonatanvlevin/offline-support-try-again-later-no-more-afc33eba79dc
文章翻译只用作知识分享。
翻译时省略了一些内容,如果翻译有误请大家纠正。

我有幸生活在一个遍布着4G网络和WIFI的国家。在家,在公司,甚至是在我朋友公寓的浴室里。但是不知为何, 我仍然遇到

image.png

或者是

image.png

也许是因为我的Pixel phone和我开了一个玩笑。哦..天哪
因特网是我曾经使用过的最不稳定的东西。95%的时间它是正常工作的,我可以流畅的播放我的喜欢的音乐没有任何问题,但是当我站在电梯里尝试发一个消息时,出问题了.
开发者生活在一个网络连接十分强大的环境中往往认为它不是问题,但事实上它是一个问题。更多的时候,就像墨菲定律那样,当用户期望你的程序运行的很快,甚至更快的时候,这种情况会伤害用户。
作为一个Android的用户发现许多安装在我手机上的程序都会提示请稍后再试时。我想努力为这种情况做点什么,至少在我做的引用程序上。
有很多关于离线如何工作的话题,例如Yigit Boyar和他的 IO talk

**作者做的APP **

image.png

在创业公司,所有人都知道要做一个最小的可行性产品(Minimum viable product 百度了一下)尝试你的想法。这个过程是至关重要而又十分艰难的。有太多的原因可能失败,因为离线而失去一个用户是绝对无法接受的。用户因为我们的应用体验不好而离开不能成为一个因素。

作者的APP用途很简单,临床医生发起一个请求,相关实验室收到请求报价,临床医生从所有的报价中选则一个。

当我们讨论用户体验时(UX),我们的决定如下:不需要任何的加载效果。应用应该能够顺利的运行,不应把用户放在一个等待的状态中。要达成的最基本目标是:网络不好的情况下,APP仍然能够正常工作。

image.png

当用户在离线的情况下,它提交的请求仍然可以成功。仅仅有一个很小的图标---同步图标,表面用户在离线状态。当它的网络正常的时候,APP将把请求发送出去,不论APP是在前台还是后台。对于每一个网络请求都是如此,除了登录和注册以外。

image.png

那么我们是如何做到这一点的呢:

首先要做的就是将页面,逻辑和持久层进行分离。

这意味着你的数据将异步的方式通过回调/Events的方式传递给Presenter层再传递到视图层。视图层只负责和用户交互,将交互的结果传递给其他模块,并接收模块的反应呈现出另一种状体。

image.png

1.本地存储我们使用SQLite,在它之上我们决定通过ContentProvider封装一下。因为它的 ContentObserver功能。ContentProvider对数据的访问和数据的操作做了很好的抽象。至于为什么不使用RxJava封装作者也给出了意见。

2.对于后台同步的任务,我们选择使用 GCMNetworkManager,它能够在满足一些确切的条件时执行指定的任务,比如说网络连接恢复,它对低电量的模式也有很好的支持。所以项目结构图如下

image.png

步骤流程

1.创建订单并同步
业务层创建一个订单传递给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通知所有的观察者有一个新的数据接入,数据状态是有待操作的

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.后台服务接收到通知后交给特定的服务去执行

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);
     //当数据改变时通知SendOrderService去执行
      Order order = (Order) msg.obj;
      Intent intent = new Intent(context, SendOrderService.class);
      intent.putExtra(SendOrderService.ORDER_ID, order.getLocalId());
      context.startService(intent);
  }
  
  //...
}

**4.服务从数据库获取到数据尝试在网络环境下同步它。更新订单的状态到同步完成通过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);
     //尝试通过网络去更新订单状态
      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.当请求失败时,将在满足.setRequiredNetwork(Task.NETWORK_STATE_CONNECTED) 条件时通过GCMNetworkManager执行一次任务,满足标准的时候 GCMNetworkManager讲执行onRunTask()回调,APP将尝试再次同步订单,如果再次失败了,将改期再执行

使用GCM 需要引入一些库,但是在国内的支持不是很好,可以考虑使用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

当同步成功以后,将通过ContentResolve去更新数据。

当然这样的架构并不是完善的,你需要考虑许多临届的情况。比如你更新了一个在服务端已经存在的订单,但是已经在服务端修改或者删除了订单。两端同时修改了一张订单怎么办。等等问题将在作者的下一篇文章提出。

I have the privilege of living in a country 我有幸生活在一个国家ˈ priv(ə)lij
I have ever used 我曾经使用过的
It will hurt your users exactly when they most need your App to work 当恰好用户需要你的程序工作会伤害到用户
I struggled to do something about it 我努力为它做点什么。
In startups 在创业公司
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 当我们讨论各种用户体验时。
we decided on the following:我们的决定如下
So basically what we want to achieve:要达成的最基本目标是
certain specific conditions met 满足具体的条件
The service obtains the data from DB and tries to sync it over the network.服务从数据库获取到数据尝试在网络环境下同步它。
the order is updated with status “synced” via ContentResolver 更新订单的状态到同步完成通过ContentResolver
When the criteria is met 当满足标准的时候
The different approaches that we took to solve these isseus 解决这些问题的不同方式

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,402评论 6 499
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,377评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,483评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,165评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,176评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,146评论 1 297
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,032评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,896评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,311评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,536评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,696评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,413评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,008评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,659评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,815评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,698评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,592评论 2 353

推荐阅读更多精彩内容