2021-07-14

2021-07-14  本文已影响0人  安卓_背包客

Google 增加了一个新 Jetpack 的成员 DataStore,主要用来替换 SharedPreferences, 而 Jetpack DataStore 有两种实现方式:

在上一篇文章 [Google] 再见 SharedPreferences 拥抱 Jetpack DataStore 中介绍了 SharedPreferences 都有那些坑,以及 Preferences DataStore 为我们解决了什么问题。

而今天这篇文章主要来介绍 Proto DataStore,Proto DataStore 通过 protocol buffers 将对象序列化存储在本地,所以首先需要安装 Protobuf 编译 proto 文件,Protobuf 编译大致分为 Gradle 插件编译和命令行编译,这两种方式已经发布到了博客上,欢迎点击下方链接前往查看。

由于目前主要在 MAC 和 ubuntu 上开发,所以只提供了这两种命令行编译方式,如果在 Win 上开发的同学,可以使用 Gradle 插件编译的方式。

这篇文章相关示例,已经上传到 GitHub 欢迎前去仓库 AndroidX-Jetpack-Practice/DataStoreSimple 切换到 datastore_proto 分支查看。

GitHub 地址:https://github.com/hi-dhl/AndroidX-Jetpack-Practice

通过这篇文章你将学习到以下内容:

为何要有 Proto DataStore

既生 Preference DataStore 何生 Proto DataStore,它们之间有什么区别?

从源码的角度

如果源码部分不是很了解,可以先忽略,继续往下看,之后回过头在来看就能理解了。

syntax = "proto2";
......
message PreferenceMap {
    map<string, Value> preferences = 1;
}

message Value {
  oneof valueName {
    bool boolean = 1;
    float float = 2;
    int32 integer = 3;
    int64 long = 4;
    string string = 5;
    double double = 7;
  }
}

在 DataStore 中使用的是 proto2 语法,将 XML 中 key-value 数据映射到 Map 中,并且在 proto 文件中只定义了 IntLongBooleanFloatString 这几种类型。

刚才说到 Proto DataStore 通过 protocol buffers 使用了二进制编码压缩,将对象序列化存储在本地,那么序列化到底是什么?我们先来了解一些基本概念,方便我们对后续的内容有更好的理解。

序列化

序列化:将一个对象转换成可存储或可传输的状态,数据可能存储在本地或者在蓝牙、网络间进行传输。序列化大概分为对象序列化、数据序列化。

对象的序列化

Java 对象序列化 将一个存储在内存中的对象转化为可传输的字节序列,便于在蓝牙、网络间进行传输或者存储在本地。把字节序列还原为存储在内存中的 Java 对象的过程称为反序列化

在 Android 中可以通过 SerializableParcelable 两种方式实现对象序列化。

Serializable

Serializable 是 Java 原生序列化的方式,主要通过 ObjectInputStream 和 ObjectOutputStream 来实现对象序列化和反序列化,但是在整个过程中用到了大量的反射和临时变量,会频繁的触发 GC,序列化的性能会非常差,但是实现方式非常简单,来看一下 ObjectInputStream 和 ObjectOutputStream 源码里有很多反射的地方。

ObjectOutputStream.java
private void writeObject0(Object obj, boolean unshared)
        throws IOException{
        ......
        Class<?> cl = obj.getClass();
        ......
}

ObjectInputStream.java
void readFields() throws IOException {
    ......
    ObjectStreamField[] fields = desc.getFields(false);
    for (int i = 0; i < objVals.length; i++) {
        objVals[i] =
            readObject0(fields[numPrimFields + i].isUnshared());
        objHandles[i] = passHandle;
    }
    ......
}

在 Android 中存在大量跨进程通信,由于 Serializable 性能差的原因,所以 Android 需要更加轻量且高效的对象序列化和反序列化机制,因此 Parcelable 出现了。

Parcelable

Parcelable 的出现解决了 Android 中跨进程通信性能差的问题,而且 ParcelableSerializable 要快很多,因为写入和读取的时候都是采用自定义序列化存储的方式,通过 writeToParcel() 方法和 describeContents() 方法来实现,不需要使用反射来推断它,因此性能得到提升,但是使用起来比 Serializable 要复杂很多。

为了解决复杂性问题, AndroidStudio 也有对应插件简化使用过程,如果是 Java 语言可以使用 android parcelable code generator 插件, 如果 Kotlin 语言的话可以使用 @Parcelize 注解,快速的实现 Parcelable 序列化。

用一张表格汇总一下 Serializable 和 Parcelable 的区别

image

数据序列化

对象序列化记录了很多信息,包括 Class 信息、继承关系信息、变量信息等等,但是数据序列化相比于对象序列化就没有这么多沉余信息,数据序列化常用的方式有 JSON、Protocol Buffers、FlatBuffers。

最后我们用一张图来分析一下 JSON、Protocol Buffers、FlatBuffers 它们序列化和反序列的性能,数据来源于 JSON vs Protocol Buffers vs FlatBuffers

[图片上传失败...(image-2ab966-1626273506259)]

FlatBuffers 和 Protocol Buffers 无论是序列化还是反序列都完胜 JSON,FlatBuffers 最初是 Google 为游戏或者其他对性能要求很高的应用开发的,接下来我们来看一下今天主角 Protocol Buffer。

Protocol Buffer

Protocol Buffer ( 简称 Protobuf ) 它是 Google 开源的跨语言编码协议,可以应用到 C++C#DartGoJavaPython 等等语言,Google 内部几乎所有 RPC 都在使用这个协议,使用了二进制编码压缩,体积更小,速度比 JSON 更快。

Proto3.0.0 Release Note 得知: protocol buffers 最初开源时,它实现了 Protocol Buffers 语言版本 2(称为 proto2), 这也是为什么版本数从 v2.0.0 开始,从 v3.0.0 开始, 引入新的语言版本(proto3),而旧的版本(proto2)继续被支持。所以到目前为止 Protobuf 共两个版本 proto2 和 proto3。

proto2 和 proto3 应该学习那个版本?

proto3 简化了 proto2 的语法,提高了开发的效率,因此也带来了版本不兼容的问题,因为 2019 年的时候才发布 proto3 稳定版本,所以在这之前使用 Protocol Buffer 的公司,大部分项目都是使用 proto2 的版本,从上文的源码分析部分可知,在 DataStore 中使用了 proto2 语法,所以 proto2 和 proto3 这两种语法都同时在使用。

对于初学者而言直接学习 proto3 语法就可以了,为了适应技术迭代的变化,当掌握 proto3 语法之后,可以顺带了解一下 proto2 语法以及 proto3 和 proto2 语法的区别,这样可以更好的理解其他的开源项目。

为了避免混淆 proto3 和 proto2 语法,在本文仅仅分析 proto3 语法,当我们了解完这些基本概念之后,我们开始分析 如何在项目中使用 Proto DataStore

如何在项目中使用 Proto DataStore

Proto DataStore 同 Preferences DataStore 一样主要应用在 MVVM 当中的 Repository 层,在项目中使用 Proto DataStore 非常简单。

1. 添加 Proto DataStore 依赖

app 模块 build.gradle 文件内,添加以下依赖

// Proto DataStore
implementation "androidx.datastore:datastore-core:1.0.0-alpha01"
// protobuf
implementation "com.google.protobuf:protobuf-javalite:3.10.0"

Google 推荐 Android 开发使用 protobuf-javalite 因为它的代码更小,做了大量的优化。

当添加完依赖之后需要新建 proto 文件,在本文示例项目中新建了一个 common-protobuf 模块,将新建的 person.proto 文件,放到了 common-protobuf 模块 src/main/proto 目录下。

proto 文件默认存放路径 src/main/proto,也可以通过修改 gradle 的配置,来修改默认存放路径

common-protobuf 模块,build.gradle 文件内,添加以下依赖

implementation "com.google.protobuf:protobuf-javalite:3.10.0"

2. 新建 Person.proto 文件,添加以下内容

syntax = "proto3";

option java_package = "com.hi.dhl.datastore.protobuf";
option java_outer_classname = "PersonProtos";

message Person {
    // 格式:字段类型 + 字段名称 + 字段编号
    string name = 1;
}

在这里只需要了解这些 proto 语法即可,在文章后面会更详细的介绍这些语法。

3. 执行 protoc ,编译 proto 文件

以输出 Java 文件为例,执行以下命令即可输出对应的 Java 文件,如果配置了 Gradle 插件,可以忽略这一步,直接点击 Build -> Rebuild Project 即可生成对应的 Java 文件。

protoc --java_out=./src/main/java -I=./src/main/proto  ./src/main/proto/*.proto

4. 构建 DataStore

object PersonSerializer : Serializer<PersonProtos.Person> {
    override fun readFrom(input: InputStream): PersonProtos.Person {
        try {
            return PersonProtos.Person.parseFrom(input) // 是编译器自动生成的,用于读取并解析 input 的消息
        } catch (exception: Exception) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    override fun writeTo(t: PersonProtos.Person, output: OutputStream) = t.writeTo(output) // t.writeTo(output) 是编译器自动生成的,用于写入序列化消息
}

5. 从 Proto DataStore 中读取数据

fun readData(): Flow<PersonProtos.Person> {
    return protoDataStore.data
        .catch {
            if (it is IOException) {
                it.printStackTrace()
                emit(PersonProtos.Person.getDefaultInstance())
            } else {
                throw it
            }
        }
}

4. 向 Proto DataStore 中写入数据

在 Proto DataStore 中是通过 DataStore.updateData() 方法写入数据的,DataStore.updateData() 是一个 suspend 函数,所以只能在协程体内使用,每当遇到 suspend 函数以挂起的方式运行,并不会阻塞主线程。

以挂起的方式运行,不会阻塞主线程 :也就是协程作用域被挂起, 当前线程中协程作用域之外的代码不会阻塞。

首先我们需要创建一个 suspend 函数,然后调用 DataStore.updateData() 方法写入数据即可。

suspend fun saveData(personModel: PersonModel) {
    protoDataStore.updateData { person ->
        person.toBuilder().setAge(personModel.age).setName(personModel.name).build()
    }
}

person.toBuilder() 是编译器为每个类生成 Builder 类,用于创建消息实例

到这里关于 Proto DataStore 读取数据和写入数据已经全部分析完了,接下来分析一下如何迁移 SharedPreferences 到 Proto DataStore

迁移 SharedPreferences 到 Proto DataStore

迁移 SharedPreferences 到 Proto DataStore 只需要 3 步

1. 创建映射关系

将 SharedPreferences 数据迁移到 Proto DataStore 中,需要实现一个映射关系,将 SharedPreferences 中每一对 key-value 数据映射到 proto 文件定义的 message 类型。

private val shardPrefsMigration =
    SharedPreferencesMigration<PersonProtos.Person>(
        context,
        SharedPreferencesRepository.PREFERENCE_NAME
    ) { sharedPreferencesView, person ->

        // 获取 SharedPreferences 的数据
        val follow = sharedPreferencesView.getBoolean(
            PreferencesKeys.KEY_ACCOUNT,
            false
        )

        // 将 SharedPreferences 每一对 key-value 的数据映射到 Proto DataStore 中
        // 将 SP 文件中  ByteCode : true 数据映射到 Person 的成员变量 followAccount 中
        person.toBuilder()
            .setFollowAccount(follow)
            .build()
    }

2. 构建 DataStore 并传入 shardPrefsMigration

protoDataStore = context.createDataStore(
    fileName = FILE_NAME,
    serializer = PersonSerializer,
    migrations = listOf(shardPrefsMigration)
)

image

到这里关于 Jetpack DataStore 实现方式之一 Proto DataStore 全部都分析完了,我们一起来看一下 proto 语法。

常用的 proto3 语法

我梳理了常用的 proto3 语法,应该能满足大部分情况,更多语法可以参考 Google 官方教程 ,当掌握 proto3 语法之后,可以顺带了解一下 Proto2 语法,Proto3 虽然简化了 Proto2 的使用,提高了开发的效率,但是因为版本兼容问题,对于早期使用 Protocol Buffer 的团队,大部分都是使用 Proto2 语法。

一个基本的消息类型

syntax = "proto3";

option java_package = "com.hi.dhl.datastore.protobuf";
option java_outer_classname = "PersonProtos";

message Person {
  // 格式:字段类型 + 字段名称 + 字段编号
    string name = 1;
    int32 age = 2;
    bool followAccount = 3;
    repeated string phone = 4;
    Address address = 5;
}

message Address{
    ......
}

字段类型

每一个消息类型中包含了很多个消息字段,每个消息字段都有一个类型,接下里用一个表格展示 proto 文件中的类型,以及对应的 Java 类型,如果其他语言可以查看官方文档。

image

以上类型是经常会用到的,当然还有其他类型:uint32uint64fixed32fixed64sfixed32sfixed64bytes 等等,更多编码类型可以点击这里查看 Encoding

字段默认值

在 Proto3 中使用以下规则,编译成 Java 语言的默认值:

字段编号

在每一个消息字段 = 号后面都跟着一个字段编号,如下所示:

string name = 1;

字段编号用于在消息的二进制格式中识别各个字段,字段编号非常重要,一旦开始使用就不能够再改变,字段编号的范围在 [1, 2^29 - 1] 之间,其中 [19000-19999] 作为 Protobuf 预留字段,不能使用。

注意 :在范围 [1, 15] 之间的字段编号在编码的时候会占用一个字节,包括字段编号和字段类型,在范围 [16, 2047] 之间的字段编号占用两个字节,因此,应该为频繁出现的消息字段保留 [1, 15] 之间的字段编号,一定要为将来频繁出现的元素留出一些空间

repeated

在刚才的示例中,我给一个字段添加了 repeated 修饰符,如下所示:

repeated string phone = 4;

被 repeated 修饰的字段,对应 Java 类型中的 List,来看一下编译后的代码。

private com.google.protobuf.Internal.ProtobufList<java.lang.String> phone_;

ProtobufList 其实是 List 子类,如下所示:

public static interface ProtobufList<E> extends List<E>

包含其他消息类型

消息字段除了可以使用 int32boolstring 等等作为字段类型,还可以使用其他消息类型作为字段类型,如下所示:

message Person {
    // 格式:字段类型 + 字段名称 + 字段编号
    Address address = 5;
}

message Address{
    ......
}

消息嵌套

在一个 proto 文件中,可以定义多个 message 如下所示:

message Person {
    // 格式:字段类型 + 字段名称 + 字段编号
    string name = 1;
    int32 age = 2;
    bool followAccount = 3;
    repeated string phone = 4;
    Address address = 5;
}

message Address{
    string city = 1
}

当然 message 也是可以层级嵌套的,来看个示例:

message Person {
    // 格式:字段类型 + 字段名称 + 字段编号
    string name = 1;
    int32 age = 2;
    bool followAccount = 3;
    repeated string phone = 4;

    message Address{
        string city = 1;
    }
    Address address = 5;
}

这些 message 会被编译成静态内部类,如下所示:

public  static final class Address extends
    com.google.protobuf.GeneratedMessageLite<
        Address, Address.Builder> implements AddressOrBuilder {
   ......
}

枚举类型

同样我们可以给 message 添加枚举类型,也可以使用枚举类型作为字段类型,如下所示:

message Person {    
    string name = 1;
    message Address{
        string city = 1;
    }
    Address address = 5;

    enum Weekday{
        SUN = 0;
        MON = 1;
        TUE = 2;
        WED = 3;
        THU = 4;
        FRI = 5;
        SAT = 6;
    }
    Weekday weekday = 6;
}

正如你所看到的,消息字段除了可以使用 int32boolstring 、其他消息类型作为字段类型之外,还可以使用枚举类型作为字段类型

注意 :每一个枚举类型第一个枚举值必须为 0,因为:

oneof

根据 Google 文档分析 oneof 有两层意思:

我们来看一下简单的示例

message PreferenceMap {
    map<string, Value> preferences = 1;
}

message Value {
  oneof valueName {
    bool boolean = 1;
    float float = 2;
    int32 integer = 3;
    int64 long = 4;
    string string = 5;
    double double = 7;
  }
}

其实在编译的时候,会为每个 oneof 生成一个 Java 枚举类型,代码如下所示

public enum ValueNameCase {
  BOOLEAN(1),
  FLOAT(2),
  INTEGER(3),
  LONG(4),
  STRING(5),
  DOUBLE(7),
  VALUENAME_NOT_SET(0); // 如果都没有赋值,会返回 `VALUENAME_NOT_SET`
  private final int value;
  private ValueNameCase(int value) {
    this.value = value;
  }

编译器会自动生成 getValueNameCase() 方法,用来检查哪个字段被赋值了,如果都没有赋值,会返回 VALUENAME_NOT_SET

常用的 proto3 语法到这里就介绍完了,文章只列举了常用的语法,如果需要把 proto3 语法都分析完,至少需要 2 篇文章才有可能介绍完,因为篇幅原因,源码分析部分会在后续的文章中分析。

上一篇 下一篇

猜你喜欢

热点阅读