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编译器编译生成针对各种平台、语言的数据访问类代码,轻松地在各种数据流中写入和读取结构化数据,尤其适用于数据存储及网络通信等场景。
总结起来即是:
优点:
- 数据大小:以独特的Varint、Zigzag编码方式及T-L-V数据存储方式实现数据压缩
- 解析效率:以高效的二进制格式实现数据的自动编码和解析
- 通用性:跨语言、跨平台
- 易用性:可用Protobuf编译器自动生成数据访问类
- 可扩展性:可随着版本迭代扩展格式
- 兼容性:可向后兼容旧格式编码的数据
- 可维护性:多个平台只需共同维护一个.proto文件
缺点:
可读性差:缺少.proto文件情况下难以去理解数据结构
既然是数据传输格式选型,那么免不了与其他数据传输格式进行比较,我们常见的与服务端交互的数据传输格式莫过于XML与JSON。
XML
可扩展标记语言(Extensible Markup Language),是一种文本类型的数据格式,以“<”开头,“>”结束的标签作为主要的语法规则。XML的设计侧重于作为文档描述,但也被广泛用于表示任意的数据结构。
优点:
- 可读性好
- 可扩展性好
缺点:
- 解析代价高,对它进行编码/解码会给应用程序带来巨大的性能损失
- 空间占用大,有效数据传输率低(大量的标签)
从事Android开发的你肯定对Android的轻量级持久化方案SharedPreference不陌生,SharedPreference即是以xml为主要实现,不过目前Android官方已建议使用DataStore作为SharedPreference的替代方案,DataStore则是以ProtoBuf为主要实现。
- JSON
JavaScript对象表示法(JavaScript Object Notation),是一种开放标准文件格式以及数据交换格式,以文本形式来存储和传输由属性值对及数组组成的数据对象,常见于与服务器的通信。
优点:
除了拥有与XML相同的优点外,由于不需要像XML那样严格的闭合标签,因此有效数据量传输率更高,可节约所占用的带宽。
ProtoBuf实现
以Gradle形式添加ProtoBuf依赖项
- 项目级别的build.gradle文件:
dependencies {
...
// Protobuf
classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.8'
}
- 模块级别的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文件,以定义我们要存储的对象的数据结构,如下:
在定义数据结构之前,我们先来思考一下,一条最基础的即时通讯消息应该要包含哪些字段?这里以生活中常见的收发信件为例子:
信件内容自然我们最关心的——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 文件中所支持的数据类型,以及自动生成的对应语言的类中的相应数据类型。
developers.google.com/protocol-bu…
分配字段编号
你可能会觉得奇怪,每个字段后带的那个数字是什么意思。这些其实是每个字段的唯一编号,用于在消息二进制格式中唯一标识我们的字段,一旦该编号被使用,就不应该再更改。
如果我们在版本迭代中想要删除某个字段,需要确保不会重复使用该字段编号,否则可能会产生诸如数据损坏等严重问题。为了确保不会发生这种状况,我们需要使用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<MessageVo> {
override fun createFromParcel(parcel: Parcel): MessageVo {
return MessageVo(parcel)
}
override fun newArray(size: Int): Array<MessageVo?> {
return arrayOfNulls(size)
}
}
现在,我们要做的就是以下两件事:
- 将来自视图层的MessageVO对象转换为数据传输层MessageDTO对象,并序列化为二进制数据格式进行消息发送。
- 接收二进制数据格式的消息,反序列化为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库,以自动化的方式帮我们简化这部分工作,敬请期待。