Android即時通訊系列文章(3)數(shù)據(jù)傳輸格式選型:資源受限的移動設(shè)備上數(shù)據(jù)傳輸?shù)睦Ь?/h2>

「椎鋒陷陳」微信技術(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)點:

  1. 數(shù)據(jù)大?。阂元毺氐腣arint、Zigzag編碼方式及T-L-V數(shù)據(jù)存儲方式實現(xiàn)數(shù)據(jù)壓縮
  2. 解析效率:以高效的二進(jìn)制格式實現(xiàn)數(shù)據(jù)的自動編碼和解析
  3. 通用性:跨語言、跨平臺
  4. 易用性:可用Protobuf編譯器自動生成數(shù)據(jù)訪問類
  5. 可擴(kuò)展性:可隨著版本迭代擴(kuò)展格式
  6. 兼容性:可向后兼容舊格式編碼的數(shù)據(jù)
  7. 可維護(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)點:

  1. 可讀性好
  2. 可擴(kuò)展性好

缺點:

  1. 解析代價高,對它進(jìn)行編碼/解碼會給應(yīng)用程序帶來巨大的性能損失
  2. 空間占用大,有效數(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依賴項

  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文件中定義要存儲的消息的數(shù)據(jù)結(jié)構(gòu)

首先,我們需要在{module}/src/main/proto目錄下新建message_dto.proto文件,以定義我們要存儲的對象的數(shù)據(jù)結(jié)構(gòu),如下:

1.png

在定義數(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)在,我們要做的就是以下兩件事:

  1. 將來自視圖層的MessageVO對象轉(zhuǎn)換為數(shù)據(jù)傳輸層MessageDTO對象,并序列化為二進(jìn)制數(shù)據(jù)格式進(jìn)行消息發(fā)送。
  2. 接收二進(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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容