良好的离线体验,让你的应用不再 Try again, later

良好的离线体验,让你的应用不再 Try again, later_第1张图片

简评:简而言之,核心思想就是 Google I/O 2016 上 Yigit Boyar 的分享:Act locally, sync globally.

虽然现在 4G 和 wifi 不断普及,但网络状况不稳定的情况还是会出现,如果对这种情况设计的不好是会有损用户体验的。特别是对创业公司,每个用户都来之不易,如果因为这样的细节问题而失去了用户,是不可接受的。

作者是一家初创公司的联合创始人和 CTO,在文中就介绍了他们的应用是如何应对离线状况下使用的问题。

作者的应用需求很简单:客户通过 App 创建基因测试的订单,相应的实验室收到消息,根据订单信息决定是否接受订单。

他们在讨论 UX 时,决定不在应用中使用任何进度条,即使可以做到很漂亮。整个 App 用起来应该很顺滑,不会让用户处于等待状态。当用户处于离线状态,他提交了订单...显示成功了。当重新处于在线状态后,应用便将请求发送到服务器,无论现在应用是否在前台。那么他们是怎么做的呢?

首先,应用采用了 MVP 架构:

本地数据库使用 SQLite,向上使用 Content Provider 来控制数据访问,后台数据同步功能则使用了 GCMNetworkManager。所以整个架构是这样的:

良好的离线体验,让你的应用不再 Try again, later_第2张图片

具体流程:

Step 1

Presenter 创建一个新的订单并通过 ContentResolver 发送给 Content Provider。


良好的离线体验,让你的应用不再 Try again, later_第3张图片
public class NewOrderPresenter extends BasePresenter {
  //...
  
  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;
  }
  
  //...
}

Step 2

Content Provider 将这条新订单添加到本地数据库并通知所有的「观察者」有了一条新订单,状态是 pending。


良好的离线体验,让你的应用不再 Try again, later_第4张图片
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;
  }
  //...
}

Step 3

后台服务监听到订单数据的变化并启动特定的服务。


良好的离线体验,让你的应用不再 Try again, later_第5张图片
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);
  }
  
  //...

}

Step 4

Service 从 DB 获取数据并通过网络进行同步。如果返回 success,通过 ContentResolver 将订单状态更新为 synced。


良好的离线体验,让你的应用不再 Try again, later_第6张图片
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 locationIds = new LabLocation().getLocationIds(this, order.getLocalId());
      orderCreate.setLabLocations(locationIds);
      Response 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);
  }
}

Step 5

如果网络请求返回 fail,就通过 GCMNetworkManager 设置一次条件任务,当条件满足时(设备连接上网络并且没有处在 doze mode),调用 onRunTask() 方法再一次同步数据。


良好的离线体验,让你的应用不再 Try again, later_第7张图片
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 locationIds = new LabLocation().getLocationIds(this, order.getLocalId());
      orderCreate.setLabLocations(locationIds);
      
      Response 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 更新本地数据状态为 synced。

良好的离线体验,让你的应用不再 Try again, later_第8张图片

当然这种方案也不是完美的,也有很多问题需要解决。作者会在接下来的文章中进一步的分享他们遇到的一些具体问题和解决办法。

对于国内开发者来说 GCMNetworkManager 是用不了的,并且各种第三方 rom 错综复杂。可以考虑用 AlarmManager,只是会复杂些,性能等方面也不如 GCMNetworkManager。

欢迎关注

  • 知乎专栏「极光日报」,每天为 Makers 导读三篇优质英文文章。
  • 网易云电台「极光日报**」,上下班路上为你读报。

你可能感兴趣的:(良好的离线体验,让你的应用不再 Try again, later)