Android即时通讯系列文章(3)数据传输格式选型:资源受限的移动设备上数据传输的困境

「椎锋陷陈」微信技术号现已开通,为了获得第一手的技术文章推送,欢迎搜索关注!

前言

跟PC时代的传统互联网相比,移动互联网得益于移动设备的便携性,仅短短数年便快速地渗透到了人们生活、工作的各个方面。虽然通信技术和硬件设备在不断地更新升级换代,但就目前而言,电量、流量等对于移动设备来讲仍属于稀缺资源。

参与过Android系统版本升级适配工作的开发人员,也许可以很明显地感受到,近年来Android系统每一个更新的版本都是往更省电、更省流量、更省内存的方向靠拢的,比如:

  • Android 6.0 引入了 低电耗模式 和 应用待机模式
  • Android 7.0 引入了 随时随地低电耗模式
  • Android 8.0 引入了 后台执行限制
  • Android 9.0 引入了 应用待机存储分区
    ...

移动应用向网络发出的请求时主要的耗电来源之一,除了发送和接收数据包本身需要消耗电量外,开启无线装置并保持唤醒也会消耗额外的电量。特别是对于即时通讯这种网络交互频繁的应用场景来讲,数据传输大小是必须要考虑优化的一个方面,要尽量做到减少冗余数据,提高传输效率,从而减少对电量、流量的损耗。

二进制数据相对于可读性更好的文本数据而言,数据冗余量小,数据排列更为紧凑,因而体积更小,传输速度更快。但是要使用自定义二进制协议的话,就意味着需要自己定义数据结构,自己做序列化反序列化工作,版本兼容也是个问题。基于时间成本与技术成本的考虑,我们决定采用Protobuf帮我们完成这部分工作。

什么是Protobuf?

Protobuf,全称Protocol Buffer(协议缓冲区),是Google开源的跨语言、跨平台、可扩展的结构化数据序列化机制。与XML、JSON及其他数据传输格式相比,Protocol更为轻巧、快速、简单。我们只需在.proto文件中定义好数据结构,即可利用Protobuf编译器编译生成针对各种平台、语言的数据访问类代码,轻松地在各种数据流中写入和读取结构化数据,尤其适用于数据存储及网络通信等场景。

总结起来即是:

优点:

  1. 数据大小:以独特的Varint、Zigzag编码方式及T-L-V数据存储方式实现数据压缩
  2. 解析效率:以高效的二进制格式实现数据的自动编码和解析
  3. 通用性:跨语言、跨平台
  4. 易用性:可用Protobuf编译器自动生成数据访问类
  5. 可扩展性:可随着版本迭代扩展格式
  6. 兼容性:可向后兼容旧格式编码的数据
  7. 可维护性:多个平台只需共同维护一个.proto文件

缺点:

可读性差:缺少.proto文件情况下难以去理解数据结构

既然是数据传输格式选型,那么免不了与其他数据传输格式进行比较,我们常见的与服务端交互的数据传输格式莫过于XML与JSON。

  • XML

    可扩展标记语言(Extensible Markup Language),是一种文本类型的数据格式,以“<”开头,“>”结束的标签作为主要的语法规则。XML的设计侧重于作为文档描述,但也被广泛用于表示任意的数据结构。

优点:

  1. 可读性好
  2. 可扩展性好

缺点:

  1. 解析代价高,对它进行编码/解码会给应用程序带来巨大的性能损失
  2. 空间占用大,有效数据传输率低(大量的标签)

从事Android开发的你肯定对Android的轻量级持久化方案SharedPreference不陌生,SharedPreference即是以xml为主要实现,不过目前Android官方已建议使用DataStore作为SharedPreference的替代方案,DataStore则是以ProtoBuf为主要实现。

  • JSON

JavaScript对象表示法(JavaScript Object Notation),是一种开放标准文件格式以及数据交换格式,以文本形式来存储和传输由属性值对及数组组成的数据对象,常见于与服务器的通信。

优点:

除了拥有与XML相同的优点外,由于不需要像XML那样严格的闭合标签,因此有效数据量传输率更高,可节约所占用的带宽。

ProtoBuf实现

以Gradle形式添加ProtoBuf依赖项

  1. 项目级别的build.gradle文件:
dependencies {
    ...
    // Protobuf
    classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.8'
}
  1. 模块级别的build.gradle文件:
apply plugin: 'com.google.protobuf'

android {
    sourceSets {
        main {
            // 定义proto文件目录
            proto {
                srcDir 'src/main/proto'
            }
        }
    }
}

dependencies {
    def PROTOBUF_VERSION = "3.0.0"

    api "com.google.protobuf:protobuf-java:${PROTOBUF_VERSION}"
    api "com.google.protobuf:protoc:${PROTOBUF_VERSION}"
}

protobuf {
    protoc { artifact = 'com.google.protobuf:protoc:3.2.0' }
    plugins {
        javalite {
            artifact = 'com.google.protobuf:protoc-gen-javalite:3.0.0'
        }
    }
    generateProtoTasks {
        all().each {
            task -> task.plugins { javalite {} }
        }
    }
}

在proto文件中定义要存储的消息的数据结构

首先,我们需要在{module}/src/main/proto目录下新建message_dto.proto文件,以定义我们要存储的对象的数据结构,如下:

1.png

在定义数据结构之前,我们先来思考一下,一条最基础的即时通讯消息应该要包含哪些字段?这里以生活中常见的收发信件为例子:

信件内容自然我们最关心的——content
谁给我寄的信,是给我还是给其他人的呢?——sender_id、target_id
为了快速检索信件,我们还需要一个唯一值——message_id
是什么类型的信件呢?是信用卡账单还是情书呢?——type
如果有多封信件,为了阅读的通顺我们还需要理清信件的时间线——timestamp

以下就是最终定义出的message_dto.proto文件,接下来让我们逐步去解读这个文件:

syntax = "proto3";

option java_package = "com.madchan.imsdk.lib.objects.bean.dto";
option java_outer_classname = "MessageDTO";

message Message {
    enum MessageType {
        MESSAGE_TYPE_UNSPECIFIED = 0;    // 未指定
        MESSAGE_TYPE_TEXT = 1;   // 文本消息
    }
    //消息唯一值
    uint64 message_id = 1;
    //消息类型
    MessageType message_type = 2;
    //消息发送用户
    string sender_id = 3;
    //消息目标用户
    string target_id = 4;
    //消息时间戳
    uint64 timestamp       = 5;
    //消息内容
    bytes content          = 6;
}

声明使用语法
syntax = "proto3";

文件首行表明我们使用的是proto3语法,默认不声明的话,ProtoBuf编译器会认为我们使用的是proto2,该声明必须位于首行,且非空、非注释。

指定文件选项
option java_package = "com.madchan.imsdk.lib.objects.bean.dto";

java_package用于指定我们要生成的Java类的包目录路径。

option java_outer_classname = "MessageDTO";

java_outer_classname指定我们要生成的Java包装类的类名。默认不声明的话,会将.proto 文件名转换为驼峰式来命名。

此外还有一个java_multiple_files选项,当为true时,会将.proto文件中声明的多个数据结构转成多个单独的.java文件。默认为false时,则会以内部类的形式只生成一个.java文件。

指定字段类型
    //消息唯一值
    uint64 message_id = 1;

也许你注意到了,针对消息唯一值message_id和消息时间戳timestamp我们采用的是uint64,这其实是unsigned int的缩写,意味无符号64位整数,即Long类型的正数,关于无符号整数的解释如下:

计算机里的数是用二进制表示的,最左边的这一位一般用来表示这个数是正数还是负数,这样的话这个数就是有符号整数。如果最左边这一位不用来表示正负,而是和后面的连在一起表示整数,那么就不能区分这个数是正还是负,就只能是正数,这就是无符号整数。

    enum MessageType {
        MESSAGE_TYPE_UNSPECIFIED = 0;    // 未指定
        MESSAGE_TYPE_TEXT = 1;   // 文本消息
    }
    //消息类型
    MessageType message_type = 2;

而描述消息类型时,由于消息类型的值通常只在一个预定义的范围之内,符合枚举特性,因此我们采用枚举来实现。这里我们先简单定义了一个未知类型和文本消息类型。

需要注意的是,每个枚举定义都必须包含一个映射到零的常量作为其第一个元素,以作为默认值。

其他的数据类型请参考此表,该表显示了.proto 文件中所支持的数据类型,以及自动生成的对应语言的类中的相应数据类型。

https://developers.google.com/protocol-buffers/docs/proto3#scalar

分配字段编号

你可能会觉得奇怪,每个字段后带的那个数字是什么意思。这些其实是每个字段的唯一编号,用于在消息二进制格式中唯一标识我们的字段,一旦该编号被使用,就不应该再更改。

如果我们在版本迭代中想要删除某个字段,需要确保不会重复使用该字段编号,否则可能会产生诸如数据损坏等严重问题。为了确保不会发生这种状况,我们需要使用reserved标识保留已删除字段的字段编号或名称,如果后续尝试使用这些字段,ProtoBuf编译器将会报错,如下:

message Message {
  reserved 3, 4 to 6;
  reserved "sender_id ", "target_id ";
}

另外一件我们需要了解的事情是,ProtoBuf中1到15范围内的字段编号只占用一个字节进行编码(包括字段编号和字段类型),而16到2047范围内的字段编号则占用两个字节。基于这个特性,我们需要为频繁出现(也即必要字段)的字段保留1到15范围内的字段进行编号,而对于可选字段而采用16到2047范围内的字段进行编号。

添加注释

我们还可以向proto文件添加注释,支持// 和 /* ... */ 语法,注释会同样保留到自动生成的对应语言的类中。

使用ProtoBuf编译器自动生成一个Java类

一切准备就绪后,我们就可以直接重新构建项目,ProtoBuf编译器会自动根据.proto文件中定义的message,在{module}/build/generated/source/proto/debug/javalite目录下生成对应包名路径的Java类文件,之后只需将该类文件拷贝到src/main/java目录下即可,我们完全可以用Gradle Task帮我们完成这项工作:

// 是否允许Proto生成DTO类
def enableGenerateProto = true
// def enableGenerateProto = false

project.tasks.whenTaskAdded { Task task ->
    if (task.name == 'generateDebugProto') {
        task.enabled = enableGenerateProto
        if(task.enabled) {
            task.doLast {
                // 复制Build目录下的DTO类到Src目录
                copy {
                    from 'build/generated/source/proto/debug/javalite'
                    into 'src/main/java'
                }
                // 删除Build目录下的DTO类
                FileTree tree = fileTree("build/generated/source/proto/debug/javalite")
                tree.each{
                    file -> delete file
                }
            }
        }
    }
}

通过阅读自动生成的MessageDTO.java文件可以看到,Protobuf编译器为每个定义好的数据结构生成了一个Java类,并为访问类中的每个字段提供了sette()r和getter()方法,且提供了Builder类用于创建类的实例。

用基于Java语言的ProtoBuf API写入和读取消息

到这里我们先把前面定义好的消息数据结构同步到MessageVO.kt,保持两个实体类的字段一致,至于为什么这样做,而不直接共用一个MessageDTO.java,下一篇文章会解释。

data class MessageVo(
    var messageId: Long,
    var messageType: Int,
    var sendId: String,
    var targetId: String,
    var timestamp: Long,
    var content: String
) : Parcelable {
    constructor(parcel: Parcel) : this(
        parcel.readLong(),
        parcel.readInt(),
        parcel.readString() ?: "",
        parcel.readString() ?: "",
        parcel.readLong(),
        parcel.readString() ?: ""
    ) {
    }

    override fun writeToParcel(parcel: Parcel, flags: Int) {
        parcel.writeLong(messageId)
        parcel.writeInt(messageType)
        parcel.writeString(sendId)
        parcel.writeString(targetId)
        parcel.writeLong(timestamp)
        parcel.writeString(content)
    }

    override fun describeContents(): Int {
        return 0
    }

    companion object CREATOR : Parcelable.Creator {
        override fun createFromParcel(parcel: Parcel): MessageVo {
            return MessageVo(parcel)
        }

        override fun newArray(size: Int): Array {
            return arrayOfNulls(size)
        }
    }

现在,我们要做的就是以下两件事:

  1. 将来自视图层的MessageVO对象转换为数据传输层MessageDTO对象,并序列化为二进制数据格式进行消息发送。
  2. 接收二进制数据格式的消息,反序列化为MessageDTO对象,并将来自数据传输层的MessageDTO对象转换为视图层的MessageVO对象。

我们把这部分工作封装到EnvelopHelper类:

class EnvelopeHelper {
    companion object {
        /**
         * 填充操作(VO->DTO)
         * @param envelope 信封类,包含消息视图对象
         */
        fun stuff(envelope: Envelope): MessageDTO.Message? {
            envelope?.messageVo?.apply {
                return MessageDTO.Message.newBuilder()
                    .setMessageId(messageId)
                    .setMessageType(MessageDTO.Message.MessageType.forNumber(messageType))
                    .setSenderId(sendId)
                    .setTargetId(targetId)
                    .setTimestamp(timestamp)
                    .setContent(ByteString.copyFromUtf8(content))
                    .build()
            }
            return null
        }

        /**
         * 提取操作(DTO->VO)
         * @param messageDTO 消息数据传输对象
         */
        fun extract(messageDTO: MessageDTO.Message): Envelope? {
            messageDTO?.apply {
                val envelope = Envelope()
                val messageVo = MessageVo(
                    messageId = messageId,
                    messageType = messageType.number,
                    sendId = senderId,
                    targetId = targetId,
                    timestamp = timestamp,
                    content = String(content.toByteArray())
                )
                envelope.messageVo = messageVo
                return envelope
            }
            return null
        }
    }
}

分别在以下两处消息收发的关键节点调用,便可完成对消息传输的序列化反序列化工作:

MessageAccessService.kt:

/** 根据MessageCarrier.aidl文件自动生成的Binder对象,需要返回给客户端 */
private val messageCarrier: IBinder = object : MessageCarrier.Stub() {
    override fun sendMessage(envelope: Envelope) {
        Log.d(TAG, "Send a message: " + envelope.messageVo?.content)
        val messageDTO = EnvelopeHelper.stuff(envelope)
        messageDTO?.let { WebSocketConnection.send(ByteString.of(*it.toByteArray())) }
        ...
    }
    ...
}

WebSocketConnection.kt:

/**
 * 在收到二进制格式消息时调用
 * @param webSocket
 * @param bytes
 */
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
      super.onMessage(webSocket, bytes)
      ...
      val messageDTO = MessageDTO.Message.parseFrom(bytes.toByteArray())
     val envelope = EnvelopeHelper.extract(messageDTO)
     Log.d(MessageAccessService.TAG, "Received a message : " + envelope?.messageVo?.content)
     ...
}
     

下一章节预告

在上面的文章中我们留下了一个疑问,即为何要拆分成MessageVO与MessageDTO两个实体对象?这其实涉及到了DDD(Domain-Driven Design,领域驱动设计)的问题,是为了实现结构分层之后的解耦而设计的,需要在不同的层次使用不同的数据模型。

不过,像文章中那种使用get/set方式逐一进行字段映射的操作毕竟太过繁琐,且容易出错,因此,下篇文章我们将介绍MapStruct库,以自动化的方式帮我们简化这部分工作,敬请期待。

「椎锋陷陈」微信技术号现已开通,为了获得第一手的技术文章推送,欢迎搜索关注!

参考

Protocol Buffers官网
https://developers.google.com/protocol-buffers/

Protocol Buffers基础:Java
https://developers.google.com/protocol-buffers/docs/javatutorial

Protocol Buffers维基百科
https://en.wikipedia.org/wiki/Protocol_Buffers

如何选择即时通讯应用的数据传输格式
http://www.52im.net/thread-276-1-1.html

强列建议将Protobuf作为你的即时通讯应用数据传输格式
http://www.52im.net/forum.php?mod=viewthread&tid=277&highlight=Protobuf

Protobuf通信协议详解:代码演示、详细原理介绍等
http://www.52im.net/forum.php?mod=viewthread&tid=323&highlight=ProtoBuf

理论联系实际:一套典型的IM通信协议设计详解
http://www.52im.net/thread-283-1-1.html

Android序列化:手把手带你分析 Protocol Buffer使用 源码
https://blog.csdn.net/carson_ho/article/details/70902349

你可能感兴趣的:(Android即时通讯系列文章(3)数据传输格式选型:资源受限的移动设备上数据传输的困境)