[译] 离线支持:不再『稍后重试』

本文讲的是[译] 离线支持:不再『稍后重试』,


离线支持:不再『稍后重试』

我很荣幸生活在一个 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 使用起来更舒心,给用户带来满足。






原文发布时间为:2017年3月24日


本文来自合作伙伴掘金,了解相关信息可以关注掘金网站。

时间: 2024-09-10 20:07:39

[译] 离线支持:不再『稍后重试』的相关文章

『 读书笔记 』4月读书总结|博文推荐

原文链接:『 读书笔记 』4月读书总结|博文推荐 写在前面 计划是每月读 5-10 本书,书籍类型大概是三个方面的:金融,技术,管理.之所以选择这三个方面,一方面是因为自己对这三个方面都很有兴趣,其次是被 linkedin 创始人 Hoffman 的 ABZ 理论 深度影响.建议大家都看看 abz 理论那篇文章,如果我有空,也会整理一些常用的这类理论模型到博客里的. 月底读书总结的形式都很简单,只是简单的一个列表和简单的书评,对觉得比较好的书会有单独的读书笔记.另外推荐大家用 excel 来做一

如何开启OS X Mavericks 的『单应用』模式?

  『单应用』模式是 OS X 一直以来就支持的一个隐藏功能,它的作用就是当你用鼠标或者触摸板在 Dock 上点击某应用程序之后,其他所有应用程序的窗口都会立刻最小化,这样可以大大的避免你在某些应用上工作时被别的应用窗口分心,而且你的屏幕空间也不会被其他乱七八糟的窗口占据. 要开启『单应用』模式,必须运行一条『终端』命令即可生效: defaults write com.apple.dock single-app -bool true && killall Dock 需要注意的是,单应用模式

使用 React.js 的渐进式 Web 应用程序:第 3 部分 - 离线支持和网络恢复能力

本文讲的是使用 React.js 的渐进式 Web 应用程序:第 3 部分 - 离线支持和网络恢复能力, 本期是新系列的第三部分,将介绍使用 Lighthouse 优化移动 web 应用传输的技巧. 并看看如何使你的 React 应用离线工作. 一个好的渐进式 Web 应用,不论网络状况如何都能立即加载,并且在不需要网络请求的情况下也能展示 UI (即离线时). 再次访问 Housing.com 渐进式 Web 应用(使用 React 和 Redux 构建)能够立即加载离线缓存的 UI. 我们可

[译] 离线友好的表单

本文讲的是[译] 离线友好的表单, 原文地址:Offline-Friendly Forms 原文作者:mxbck 译文出自:掘金翻译计划 本文永久链接:github.com/xitu/gold-m- 译者:sunui 校对者:yanyixin.Tina92 网络不佳时网页表单的表现通常并不理想.如果你试图在离线状态下提交表单,那就很可能丢失刚刚填好的数据.下面就看看我们是如何修复这个问题的. 太长,勿点:这里是本文的 CodePen Demo. 随着 Service Workers 的推行,现在

中国胖子合伙人 『瘦瘦』安卓2.0全新体验

近日,中国合伙人全国热映,中国式的梦想,让我们明白,在创造传奇的成功道路上,少不了志同道合的合伙人的.瘦瘦-健康减肥顾问,作为一款健康类移动应用,也号召那些志同道合的朋友们共同加入到中国胖子合伙人的行列中,一起瘦瘦,让减肥的道路不再艰辛,不再孤独.『瘦瘦』会根据你的性别.身高.体重.年龄等资料;以及肥胖原因的评估结果;为你量身打造一套专业的健康减肥计划;对你的日常饮食进行一对一的指导;让你在日常走路.散步等运动中不知不觉的甩掉脂肪;瘦瘦还会时时对你的减肥过程进行监督.瘦瘦-健康减肥顾问,上线半年

『产品经理』的使命是什么

文章开始之前我们先回顾一下『产品经理』的使命是什么? ---帮助企业或者团队打造出有价值的产品. 言归正传. 那么产品有哪些阶段呢? ---从无到有,从有到优. 这里我换个方式展开来讲(0-10分). 从0到1的阶段 这个阶段是孕育产品的起点,也是最难的阶段之一,V1.0的版本会来到用户的身边,产品从立项到进入第一个迭代,期中需要『产品经理』介入的程度相当之深,也是最考究『产品经理』功力的阶段.可以这么说,3年及以上的产品经理一般才可以很好的将这个过程处理的很完善,为什么?因为需要挖掘出产品的核

阿里云邮箱系统登录异常,(错误码6),请稍后重试。(H058811A57YLFD)

我的阿里云邮箱 hi31590483@aliyun.com ,登陆后显示:系统登录异常,(错误码6),请稍后重试.(H058811A57YLFD),怎么解决?

Win8登陆迅游加速器提示“登录请求失败,请稍后重试”如何解决

Win8登陆迅游加速器提示"登录请求失败,请稍后重试"如何解决   解决办法: 1.网上邻居-右键属性(注:Vista/Win7系统请点击"网络和共享中心"左侧的"管理网络连接/更改适配器设置"); 2.本地连接-右键属性-Internet协议(TCP/IP4)属性中选中"使用下面的DNS服务器地址"; 3.在"首选DNS服务器"中,网通/联通/电信用户填"211.157.15.189"

此时无足够的可用内存,无法满足操作的预期要求,可能是由于虚拟地址空间碎片造成的,请稍后重试

如图: 使用Visual Studio 2010 一段时间,会经常遇到"此时无足够的可用内存,无法满足操作的预期要求,可能是由于虚拟地址空间碎片造成的,请稍后重试" 解决方法:打个微软补丁 补丁地址:https://connect.microsoft.com/VisualStudio/Downloads/DownloadDetails.aspx?DownloadID=29729