让Retrofit与Realm、Parceler一起使用

英文原文: Using Retrofit with Realm and Parceler  。

Retrofit是一个绝大多数app都会考虑使用的一个库。如果你的app需要一个后端,那么你就应该使用Retrofit去和RESTful服务交互。它的使用极为简单,你可以瞬间就让网络运行起来,Retrofit自动把获取的响应结果解析到你的model对象中。

通常,你还会考虑把从后端获取的某些数据保存在本地。Realm现在非常流行,用它你可以使用“普通”的对象,而不是使用SQLite(虽然有很多ORM库存在)。而且,RealmObject是live Object,因此你总是能得到最新的数据。

另一个经常出现的问题是你可能只想保存一部分获取的对象到数据库,但是剩下的数据你仍然需要在Activity被杀死的时候使用onSaveInstanceState保存下来(比如一个一个本地缓存的书签或者喜欢item的列表)。Parceler 为这个问题提供了一个简单的解决方法。因为它可以通过注解处理器让你的对象可序列化。

这三个库一起可以让处理后端数据变的简单快速。这篇博客将带你学习设置过程。

Retrofit

本文假设你的后台公开的是一个RESTful服务,响应是JSON格式的。对于JSON的解析,我们将使用Gson。在build.gradle中添加如下依赖:

compile 'com.google.code.gson:gson:2.6.2'
compile 'com.squareup.retrofit2:retrofit:2.0.2'
compile 'com.squareup.retrofit2:converter-gson:2.0.2'
compile 'com.squareup.okhttp3:okhttp:3.2.0'
compile 'com.squareup.okhttp3:logging-interceptor:3.2.0'

第一步就是定义你的model classes,只需创建一个简单的对象。

public class Country {
  public String alpha2Code;
  public String name;
  public String region;
  public List<String> languages;
  ...
}

现在我们来定义网络调用(Call)以备用。在Retrofit中,这是通过创建一个接口并为每一个调用定义一个方法来完成的。每一个方法的返回类型是Call。当然如果call的返回是一个数组,也可以是Call<List>。每一个call的参数可以使用@Query,@Path 或者 @Body注解,这取决于你的需要。最后,用HTPP请求类型和URL(相对于服务的根地址)来注解这个调用。比如:

public interface ICountryApi {
  @GET("region/{region}")
  Call<List<Country>> getAllCountries(@Path("region") String region);
}

现在是发挥Retrofit神奇之处的时候了。Retrofit做了所有繁重的工作并创建了一个实现了你的接口的对象。这个对象应该可以被重复使用。比如可以使用依赖注入或者单例来实现重用。

Gson gson = new GsonBuilder()
  .setExclusionStrategies(new ExclusionStrategy() {
    // This is required to make Gson work with RealmObjects
    @Override public boolean shouldSkipField(FieldAttributes f) {
      return f.getDeclaringClass().equals(RealmObject.class);
    }
 
    @Override public boolean shouldSkipClass(Class<?> clazz) {
      return false;
    }
  }).create();
 
OkHttpClient.Builder httpClientBuilder = new OkHttpClient.Builder();
 
if(BuildConfig.DEBUG) {
  // enable logging for debug builds
  HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor();
  loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
  httpClientBuilder.addInterceptor(loggingInterceptor);
}
 
ICountryApi countryApi = new Retrofit.Builder()
      .baseUrl("https://restcountries.eu/rest/v1/")
      .addConverterFactory(GsonConverterFactory.create(gson))
      .callFactory(httpClientBuilder.build())
      .build().create(ICountryApi.class);

现在你就使用service实例做后台调用了。Call可以通过Call.execute()以同步方式执行,它直接返回一个Response,如果失败抛出异常;或者通过基于回调的Call.enqueue()异步的执行。绝大多数情况下,你想用的都是异步的enqueue()。

countryApi.getAllCountries().enqueue(new Callback<List<Country>>() {
  @Override public void onResponse(Call<List<Country>> call, Response<List<Country>> response) {
    if(response.isSuccessful()) {
      List<Country> countries = response.body();
    } else {
      // handle error
    }
  }
 
  @Override public void onFailure(Call<List<Country>> call, Throwable t) {
    // handle error
  }
});

添加对RxJava的支持

后端调用同样可以用RxJava Observable作为返回类型,这样可以利用Observable带来的好处,比如链式调用。为此,你需要再添加一个依赖并且在创建Retrofit service的时候添加一个call adapter factory:

  compile 'com.squareup.retrofit2:adapter-rxjava:2.0.2'
 
new Retrofit.Builder()
  .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
  ...

Realm

为了把Realm引入你的app,你需要添加一个buildscript dependency "io.realm:realm-gradle-plugin:0.88.2"并且在app的build.gradle中应用一个plugin:apply plugin: 'realm-android'。记住Realm会让你的apk增大约3m,因为为了兼容多个CPU架构,Realm要使用的本地libs必须包含进来。不过你可以使用APK Splits来减小这个负担。

RealmObject是通过打开和查询一个Realm获得。一个Realm总是被限制在一个线程中,如果你想在另一个线程中获取RealmObject,必须打开一个新的Realm。但是因为RealmObject是live Object,你总能得到最新的数据,不过如果你的线程不是一个looper线程,你需要手动调用Realm instance的 refresh() 才能看见发生变化变化。你也可以实现interface,添加自定义的setters/getters方法,或者直接使用public 成员。但是对象仍然必须继承RealmObject。你还可以通过注解添加一个primary key或者定义索引。更多信息参见Realm 文档

public class Country extends RealmObject {
  @PrimaryKey
  public String alpha2Code;
  public String name;
  public String region;
  public RealmList<RealmString> languages;
  ...
}

另一个(烦人)的限制是一个RealmList只能包含RealmObject。因此你需要如下封装基本数据类型和String:

public class RealmString extends RealmObject {
  public String value;
}

但是,现在Retrofit 和 Gson的问题来了。Gson并不知道RealmString是一个封装的对象。。。因此让我们实现一个TypeAdapter来让它正常工作:

public class RealmStringListTypeAdapter extends TypeAdapter<RealmList<RealmString>> {
  public static final TypeAdapter<RealmList<RealmString>> INSTANCE = 
      new RealmStringListTypeAdapter().nullSafe();
 
  private RealmStringListTypeAdapter() { }
 
  @Override public void write(JsonWriter out, RealmList<RealmString> src) throws IOException {
    out.beginArray();
    for(RealmString realmString : src) { out.value(realmString.value); }
    out.endArray();
  }
 
  @Override public RealmList<RealmString> read(JsonReader in) throws IOException {
    RealmList<RealmString> realmStrings = new RealmList<>();
    in.beginArray();
    while (in.hasNext()) {
      if(in.peek() == JsonToken.NULL) {
        in.nextNull();
      } else {
        RealmString realmString = new RealmString();
        realmString.value = in.nextString();
        realmStrings.add(realmString);
      }
    }
    in.endArray();
    return realmStrings;
  }
}

你必须在建立Gson实例的时候注册这个TypeAdapter:

new GsonBuilder()
  .registerTypeAdapter(new TypeToken<RealmList<RealmString>>(){}.getType(),
                        RealmStringListTypeAdapter.INSTANCE)
  ...

现在你的RealmObject就能被持久化了。使用Realm的时候,写入操作需要被写在Realm 事务代码之间:

Realm realm = Realm.getDefaultInstance();
List<Country> countries = response.body();
realm.beginTransaction();
realm.copyToRealmOrUpdate(countries);
realm.commitTransaction();

保存了这个对象之后,它们就能通过Realm的query方法取出 - 但是记住 live RealmObject是受线程限制的。

List<Country> allSavedCountries = realm.allObjects(Country.class);
Country specificCountry = realm.where(Country.class).equalTo("alpha2Code", "AT").findFirst();

使用这种方法,你很容易混淆live RealmObject与同一类的detached object。如果你想得到一个detached 拷贝,你可以使用realm.copyFromRealm(liveRealmObject);你可以使用realmObject.isValid()去检查一个RealmObject是一个 live instance还是一个detached 拷贝。 ps :没看懂。

Parceler

在安卓中要传递数据或者保存状态,对象需要实现Serializable或者Parcelable。Parcelable被认为更快,因为它没有反射的负担(以及更少的内存),因此更适合移动app。但是实现一个Parcelable需要做更多的工作。虽然Android Studio有一个自动生成代码的工具,但是每次class改变的时候都要重复这一步。Parceler可以解决这个问题。

因为Parceler使用了一个注解处理器,因此首先需要应用Android APT 插件,那样你的IDE才能知道生成的类,而注解处理产生的代码菜不会包含在apk中。添加'com.neenbedankt.gradle.plugins:android-apt:1.8' buildscript dependency并apply plugin: 'com.neenbedankt.android-apt'。然后你就能在依赖中添加Parceler lib了:

compile 'org.parceler:parceler-api:1.1.1'
apt 'org.parceler:parceler:1.1.1'

有了Parceler,你可以使用@Parcel 来注解class,注解处理器将自动为你创建一个Parcelable的实现。为了让它能和RealmObject工作,你需要一些额外的配置:

@Parcel(implementations = { CountryRealmProxy.class },
        value = Parcel.Serialization.FIELD,
        analyze = { Country.class })
public class Country extends RealmObject {
  @PrimaryKey
  public String alpha2Code;
  public String name;
  public String region;
  @ParcelPropertyConverter(RealmListParcelConverter.class)
  public RealmList<RealmString> languages;
  ...
}

通过设置analyze与implementations属性,告诉Parcele接受CountryRealmProxy对象-Realm使用的代理class。如果你的RealmObject的RealmProxy类不存在,尝试编译项目,就像这一步里它被生成的那样。

Parceler默认并不知道如何处理一个RealmList。你必须提供一个自定义的ParcelConverter。我写了一个可以和任意RealmList工作的RealmListParcelConverter,只要 item也是用@Parcel注解的。

public class RealmListParcelConverter
        implements TypeRangeParcelConverter<RealmList<? extends RealmObject>, 
                                            RealmList<? extends RealmObject>> {
  private static final int NULL = -1;
 
  @Override
  public void toParcel(RealmList<? extends RealmObject> input, Parcel parcel) {
    parcel.writeInt(input == null ? NULL : input.size());
    if (input != null) {
      for (RealmObject item : input) {
        parcel.writeParcelable(Parcels.wrap(item), 0);
      }
    }
  }
 
  @Override
  public RealmList fromParcel(Parcel parcel) {
    int size = parcel.readInt();
    RealmList list = new RealmList();
    for (int i=0; i<size; i++) {
      Parcelable parcelable = parcel.readParcelable(getClass().getClassLoader());
      list.add((RealmObject) Parcels.unwrap(parcelable));
    }
    return list;
  }
}

要把@Parcel 注解的对象当作Parcelable使用,你必须用Parcels.wrap(object)来封装它们。如果要从一个Parcelable中获得原始的对象,调用Parcels.unwrap(parcelable)。这些方法对@Parcel注解的对象的list同样适用。

结论

设置Retrofit与Realm和Parceler一起使用需要些初始工作,但是使用起来真的很简单:

List<Country> countries;
 
public void getCountries() {
  countryApi.getAllCountries().enqueue(new Callback<List<Country>>() {
    @Override public void onResponse(Call<List<Country>> call, Response<List<Country>> response) {
      if(response.isSuccessful()) {
        countries = response.body();
        realm.beginTransaction();
        // Copy the objects to realm. The list still contains detached objects.
        // If you want to use the live objects, you have to use the return value
        // of this call.
        realm.copyToRealmOrUpdate(countries);
        realm.commitTransaction();
      } else {
        // handle error
      }
    }
 
    @Override public void onFailure(Call<List<Country>> call, Throwable t) {
      // handle error
    }
  });
}
 
@Override
protected void onSaveInstanceState(Bundle outState) {
  super.onSaveInstanceState(outState);
  outState.putParcelable("countries", Parcels.wrap(countries));
}
 
@Override
protected void onCreate(Bundle savedInstanceState) {
  ...
  if(savedInstanceState != null) {
      countries = Parcels.unwrap(savedInstanceState.getParcelable("countries"));
  }
}

本文相关项目可以在 GitHub上获取