「椎鋒陷陳」微信技術(shù)號現(xiàn)已開通,為了獲得第一手的技術(shù)文章推送,歡迎搜索關(guān)注!
前言
跟PC時代的傳統(tǒng)互聯(lián)網(wǎng)相比,移動互聯(lián)網(wǎng)得益于移動設(shè)備的便攜性,僅短短數(shù)年便快速地滲透到了人們生活、工作的各個方面。雖然通信技術(shù)和硬件設(shè)備在不斷地更新升級換代,但就目前而言,電量、流量等對于移動設(shè)備來講仍屬于稀缺資源。
參與過Android系統(tǒng)版本升級適配工作的開發(fā)人員,也許可以很明顯地感受到,近年來Android系統(tǒng)每一個更新的版本都是往更省電、更省流量、更省內(nèi)存的方向靠攏的,比如:
- Android 6.0 引入了 低電耗模式 和 應(yīng)用待機模式
- Android 7.0 引入了 隨時隨地低電耗模式
- Android 8.0 引入了 后臺執(zhí)行限制
- Android 9.0 引入了 應(yīng)用待機存儲分區(qū)
...
移動應(yīng)用向網(wǎng)絡(luò)發(fā)出的請求時主要的耗電來源之一,除了發(fā)送和接收數(shù)據(jù)包本身需要消耗電量外,開啟無線裝置并保持喚醒也會消耗額外的電量。特別是對于即時通訊這種網(wǎng)絡(luò)交互頻繁的應(yīng)用場景來講,數(shù)據(jù)傳輸大小是必須要考慮優(yōu)化的一個方面,要盡量做到減少冗余數(shù)據(jù),提高傳輸效率,從而減少對電量、流量的損耗。
二進(jìn)制數(shù)據(jù)相對于可讀性更好的文本數(shù)據(jù)而言,數(shù)據(jù)冗余量小,數(shù)據(jù)排列更為緊湊,因而體積更小,傳輸速度更快。但是要使用自定義二進(jìn)制協(xié)議的話,就意味著需要自己定義數(shù)據(jù)結(jié)構(gòu),自己做序列化反序列化工作,版本兼容也是個問題?;跁r間成本與技術(shù)成本的考慮,我們決定采用Protobuf幫我們完成這部分工作。
什么是Protobuf?
Protobuf,全稱Protocol Buffer(協(xié)議緩沖區(qū)),是Google開源的跨語言、跨平臺、可擴(kuò)展的結(jié)構(gòu)化數(shù)據(jù)序列化機制。與XML、JSON及其他數(shù)據(jù)傳輸格式相比,Protocol更為輕巧、快速、簡單。我們只需在.proto文件中定義好數(shù)據(jù)結(jié)構(gòu),即可利用Protobuf編譯器編譯生成針對各種平臺、語言的數(shù)據(jù)訪問類代碼,輕松地在各種數(shù)據(jù)流中寫入和讀取結(jié)構(gòu)化數(shù)據(jù),尤其適用于數(shù)據(jù)存儲及網(wǎng)絡(luò)通信等場景。
總結(jié)起來即是:
優(yōu)點:
- 數(shù)據(jù)大?。阂元毺氐腣arint、Zigzag編碼方式及T-L-V數(shù)據(jù)存儲方式實現(xiàn)數(shù)據(jù)壓縮
- 解析效率:以高效的二進(jìn)制格式實現(xiàn)數(shù)據(jù)的自動編碼和解析
- 通用性:跨語言、跨平臺
- 易用性:可用Protobuf編譯器自動生成數(shù)據(jù)訪問類
- 可擴(kuò)展性:可隨著版本迭代擴(kuò)展格式
- 兼容性:可向后兼容舊格式編碼的數(shù)據(jù)
- 可維護(hù)性:多個平臺只需共同維護(hù)一個.proto文件
缺點:
可讀性差:缺少.proto文件情況下難以去理解數(shù)據(jù)結(jié)構(gòu)
既然是數(shù)據(jù)傳輸格式選型,那么免不了與其他數(shù)據(jù)傳輸格式進(jìn)行比較,我們常見的與服務(wù)端交互的數(shù)據(jù)傳輸格式莫過于XML與JSON。
-
XML
可擴(kuò)展標(biāo)記語言(Extensible Markup Language),是一種文本類型的數(shù)據(jù)格式,以“<”開頭,“>”結(jié)束的標(biāo)簽作為主要的語法規(guī)則。XML的設(shè)計側(cè)重于作為文檔描述,但也被廣泛用于表示任意的數(shù)據(jù)結(jié)構(gòu)。
優(yōu)點:
- 可讀性好
- 可擴(kuò)展性好
缺點:
- 解析代價高,對它進(jìn)行編碼/解碼會給應(yīng)用程序帶來巨大的性能損失
- 空間占用大,有效數(shù)據(jù)傳輸率低(大量的標(biāo)簽)
從事Android開發(fā)的你肯定對Android的輕量級持久化方案SharedPreference不陌生,SharedPreference即是以xml為主要實現(xiàn),不過目前Android官方已建議使用DataStore作為SharedPreference的替代方案,DataStore則是以ProtoBuf為主要實現(xiàn)。
- JSON
JavaScript對象表示法(JavaScript Object Notation),是一種開放標(biāo)準(zhǔn)文件格式以及數(shù)據(jù)交換格式,以文本形式來存儲和傳輸由屬性值對及數(shù)組組成的數(shù)據(jù)對象,常見于與服務(wù)器的通信。
優(yōu)點:
除了擁有與XML相同的優(yōu)點外,由于不需要像XML那樣嚴(yán)格的閉合標(biāo)簽,因此有效數(shù)據(jù)量傳輸率更高,可節(jié)約所占用的帶寬。
ProtoBuf實現(xiàn)
以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文件中定義要存儲的消息的數(shù)據(jù)結(jié)構(gòu)
首先,我們需要在{module}/src/main/proto目錄下新建message_dto.proto文件,以定義我們要存儲的對象的數(shù)據(jù)結(jié)構(gòu),如下:

在定義數(shù)據(jù)結(jié)構(gòu)之前,我們先來思考一下,一條最基礎(chǔ)的即時通訊消息應(yīng)該要包含哪些字段?這里以生活中常見的收發(fā)信件為例子:
信件內(nèi)容自然我們最關(guān)心的——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;
//消息發(fā)送用戶
string sender_id = 3;
//消息目標(biāo)用戶
string target_id = 4;
//消息時間戳
uint64 timestamp = 5;
//消息內(nèi)容
bytes content = 6;
}
聲明使用語法
syntax = "proto3";
文件首行表明我們使用的是proto3語法,默認(rèn)不聲明的話,ProtoBuf編譯器會認(rèn)為我們使用的是proto2,該聲明必須位于首行,且非空、非注釋。
指定文件選項
option java_package = "com.madchan.imsdk.lib.objects.bean.dto";
java_package用于指定我們要生成的Java類的包目錄路徑。
option java_outer_classname = "MessageDTO";
java_outer_classname指定我們要生成的Java包裝類的類名。默認(rèn)不聲明的話,會將.proto 文件名轉(zhuǎn)換為駝峰式來命名。
此外還有一個java_multiple_files選項,當(dāng)為true時,會將.proto文件中聲明的多個數(shù)據(jù)結(jié)構(gòu)轉(zhuǎn)成多個單獨的.java文件。默認(rèn)為false時,則會以內(nèi)部類的形式只生成一個.java文件。
指定字段類型
//消息唯一值
uint64 message_id = 1;
也許你注意到了,針對消息唯一值message_id和消息時間戳timestamp我們采用的是uint64,這其實是unsigned int的縮寫,意味無符號64位整數(shù),即Long類型的正數(shù),關(guān)于無符號整數(shù)的解釋如下:
計算機里的數(shù)是用二進(jìn)制表示的,最左邊的這一位一般用來表示這個數(shù)是正數(shù)還是負(fù)數(shù),這樣的話這個數(shù)就是有符號整數(shù)。如果最左邊這一位不用來表示正負(fù),而是和后面的連在一起表示整數(shù),那么就不能區(qū)分這個數(shù)是正還是負(fù),就只能是正數(shù),這就是無符號整數(shù)。
enum MessageType {
MESSAGE_TYPE_UNSPECIFIED = 0; // 未指定
MESSAGE_TYPE_TEXT = 1; // 文本消息
}
//消息類型
MessageType message_type = 2;
而描述消息類型時,由于消息類型的值通常只在一個預(yù)定義的范圍之內(nèi),符合枚舉特性,因此我們采用枚舉來實現(xiàn)。這里我們先簡單定義了一個未知類型和文本消息類型。
需要注意的是,每個枚舉定義都必須包含一個映射到零的常量作為其第一個元素,以作為默認(rèn)值。
其他的數(shù)據(jù)類型請參考此表,該表顯示了.proto 文件中所支持的數(shù)據(jù)類型,以及自動生成的對應(yīng)語言的類中的相應(yīng)數(shù)據(jù)類型。
https://developers.google.com/protocol-buffers/docs/proto3#scalar
分配字段編號
你可能會覺得奇怪,每個字段后帶的那個數(shù)字是什么意思。這些其實是每個字段的唯一編號,用于在消息二進(jìn)制格式中唯一標(biāo)識我們的字段,一旦該編號被使用,就不應(yīng)該再更改。
如果我們在版本迭代中想要刪除某個字段,需要確保不會重復(fù)使用該字段編號,否則可能會產(chǎn)生諸如數(shù)據(jù)損壞等嚴(yán)重問題。為了確保不會發(fā)生這種狀況,我們需要使用reserved標(biāo)識保留已刪除字段的字段編號或名稱,如果后續(xù)嘗試使用這些字段,ProtoBuf編譯器將會報錯,如下:
message Message {
reserved 3, 4 to 6;
reserved "sender_id ", "target_id ";
}
另外一件我們需要了解的事情是,ProtoBuf中1到15范圍內(nèi)的字段編號只占用一個字節(jié)進(jìn)行編碼(包括字段編號和字段類型),而16到2047范圍內(nèi)的字段編號則占用兩個字節(jié)?;谶@個特性,我們需要為頻繁出現(xiàn)(也即必要字段)的字段保留1到15范圍內(nèi)的字段進(jìn)行編號,而對于可選字段而采用16到2047范圍內(nèi)的字段進(jìn)行編號。
添加注釋
我們還可以向proto文件添加注釋,支持// 和 /* ... */ 語法,注釋會同樣保留到自動生成的對應(yīng)語言的類中。
使用ProtoBuf編譯器自動生成一個Java類
一切準(zhǔn)備就緒后,我們就可以直接重新構(gòu)建項目,ProtoBuf編譯器會自動根據(jù).proto文件中定義的message,在{module}/build/generated/source/proto/debug/javalite目錄下生成對應(yīng)包名路徑的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 {
// 復(fù)制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編譯器為每個定義好的數(shù)據(jù)結(jié)構(gòu)生成了一個Java類,并為訪問類中的每個字段提供了sette()r和getter()方法,且提供了Builder類用于創(chuàng)建類的實例。
用基于Java語言的ProtoBuf API寫入和讀取消息
到這里我們先把前面定義好的消息數(shù)據(jù)結(jié)構(gòu)同步到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)
}
}
現(xiàn)在,我們要做的就是以下兩件事:
- 將來自視圖層的MessageVO對象轉(zhuǎn)換為數(shù)據(jù)傳輸層MessageDTO對象,并序列化為二進(jìn)制數(shù)據(jù)格式進(jìn)行消息發(fā)送。
- 接收二進(jìn)制數(shù)據(jù)格式的消息,反序列化為MessageDTO對象,并將來自數(shù)據(jù)傳輸層的MessageDTO對象轉(zhuǎn)換為視圖層的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 消息數(shù)據(jù)傳輸對象
*/
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
}
}
}
分別在以下兩處消息收發(fā)的關(guān)鍵節(jié)點調(diào)用,便可完成對消息傳輸?shù)男蛄谢葱蛄谢ぷ鳎?/p>
MessageAccessService.kt:
/** 根據(jù)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:
/**
* 在收到二進(jìn)制格式消息時調(diào)用
* @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)
...
}
下一章節(jié)預(yù)告
在上面的文章中我們留下了一個疑問,即為何要拆分成MessageVO與MessageDTO兩個實體對象?這其實涉及到了DDD(Domain-Driven Design,領(lǐng)域驅(qū)動設(shè)計)的問題,是為了實現(xiàn)結(jié)構(gòu)分層之后的解耦而設(shè)計的,需要在不同的層次使用不同的數(shù)據(jù)模型。
不過,像文章中那種使用get/set方式逐一進(jìn)行字段映射的操作畢竟太過繁瑣,且容易出錯,因此,下篇文章我們將介紹MapStruct庫,以自動化的方式幫我們簡化這部分工作,敬請期待。
「椎鋒陷陳」微信技術(shù)號現(xiàn)已開通,為了獲得第一手的技術(shù)文章推送,歡迎搜索關(guān)注!
參考
Protocol Buffers官網(wǎng)
https://developers.google.com/protocol-buffers/
Protocol Buffers基礎(chǔ):Java
https://developers.google.com/protocol-buffers/docs/javatutorial
Protocol Buffers維基百科
https://en.wikipedia.org/wiki/Protocol_Buffers
如何選擇即時通訊應(yīng)用的數(shù)據(jù)傳輸格式
http://www.52im.net/thread-276-1-1.html
強列建議將Protobuf作為你的即時通訊應(yīng)用數(shù)據(jù)傳輸格式
http://www.52im.net/forum.php?mod=viewthread&tid=277&highlight=Protobuf
Protobuf通信協(xié)議詳解:代碼演示、詳細(xì)原理介紹等
http://www.52im.net/forum.php?mod=viewthread&tid=323&highlight=ProtoBuf
理論聯(lián)系實際:一套典型的IM通信協(xié)議設(shè)計詳解
http://www.52im.net/thread-283-1-1.html
Android序列化:手把手帶你分析 Protocol Buffer使用 源碼
https://blog.csdn.net/carson_ho/article/details/70902349