Google 在移动平台(Android 和 iOS)上提供了独立的 Search App,但它不仅仅是用一个移动浏览器封装了 Google Web Search,而是做了很多移动应用相关的改进。这个系列文章,通过抓包对 Android Google App 与 Server 间通讯协议进行简单分析,管中窥豹,以见一斑。
- Google App API Protocol - Text Search
- Google App API Protocol - Voice Search
- Google App API Protocol - Search History
语音搜索
语音搜索与文本搜索不同,必须先进行语音识别(语音转文字),拿到识别结果后才能进行搜索,拿到搜索结果。语音识别过程是一个录音数据流上传过程,一般采取的都是流式分片上传。Google App 的语音搜索,仍然是沿袭 Google 一贯注重效率的风格,使用两个 HTTPS 请求,完成了录音数据的流式上传,以及识别结果、搜索结果和语音播报的流式下发。具体的做法是:
在用户发起语音请求时,Google App 与 Google 服务器同时建立两个 HTTPS POST 连接,以 URL 参数 "pair" 标识这是同一用户的一对连接:
https://www.google.com/m/voice-search/up?pair=4fc4d987-3f06-49d5-9f3a-986937a5e5fe https://www.google.com/m/voice-search/down?pair=4fc4d987-3f06-49d5-9f3a-986937a5e5fe
Google App 基于 chunked 编码,在 up 连接中实现录音数据流式上传,在 down 连接中实现识别结果、搜索结果和语音播报的流式下发。
语音搜索 up 连接
在 up 连接中,Google App 仅利用了 POST 请求通路的 chunked 编码流式上传录音数据,直到所有数据上传完成,Google 服务器才会返回一个 Content-Length 为 0 的 200 响应消息。而且 POST 请求的 HTTP Header/Params 很简单,仅仅包含基本的信息。
Host www.google.com Connection keep-alive Transfer-Encoding chunked Cache-Control no-cache, no-store X-S3-Send-Compressible 1 User-Agent Mozilla/5.0 (Linux; Android 4.4.4; HM 1S Build/KTU84P) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/33.0.0.0 Mobile Safari/537.36 GSA/5.9.33.19.arm Content-Type application/octet-stream Accept-Encoding gzip, deflate
这里要注意 Cotent-Type 是 application/octet-stream,这代表上传的是二进制数据流,需要用专门的协议才能 decode 到真正内容。这给解析协议带来了很大的困难。简单观察二进制数据流,发现部分内容是遵从 protobuf 协议格式,但直接使用 application/x-protobuffer 的 Dilimited List 封包格式进行 docode 并不成功。无奈之下,只好采取比较笨的方法,观察二进制数据流,跳过那些明显不符合 protobuf 协议格式的部分,尽最大努力把 protobuf 消息先解包出来。然后再根据已经解包出来的部分,逐步推测未解析部分的编码格式。经过较长时间的分析,拿到了 Google App 语音搜索 up 连接的请求消息封包格式大致如下:
(10 bytes Header) (Big Endian Fixed32 of Msg Len)(Msg) (Big Endian Fixed32 of Msg Len)(Msg) ...... (Big Endian Fixed32 of Msg Len)(Msg)
对于 Header 长度是否恒为 10 字节,我持怀疑态度,有待通过更多分析发现规律。做了很多二进制码的分析工作,到最后才发现消息结构如此简单,让人不得不抱怨:Google 你直接用和文本搜索一样的 application/x-protobuffer 格式不得了吗?还搞得那么复杂!不过也许有一定历史原因吧。
和文本搜索一样,先建立一个 Empty protobuf Message,逐步去推导流式协议里 Message 的结构,尽最大猜测推导出来的 Google 语音搜索请求消息的 .proto 可能是这样的:
$ more voicesearch.proto package com.google.search.app; message VoiceSearchRequest { optional int32 fin_stream = 3; optional UserInfo user_info = 293000; optional VoiceSampling voice_sampling = 293100; optional VoiceData voice_data = 293101; optional ClientInfo client_info = 294000; optional UserPreference user_preference = 294500; optional Empty VSR27301014 = 27301014; optional Empty VSR27423252 = 27423252; optional Double VSR27801516 = 27801516; optional GetMethod get_method = 34352150; optional Empty VSR61914024 = 61914024; optional Empty VSR77499489 = 77499489; optional Empty VSR82185720 = 82185720; } message UserInfo { optional Empty lang = 2; optional Empty locale = 3; optional string uid = 5; optional GoogleNow google_now = 9; } message GoogleNow { optional string auth_url = 1; optional string auth_key = 2; } message VoiceSampling { optional float sample_rate = 2; } message VoiceData { optional bytes amr_stream = 1; } message ClientInfo { optional string type = 2; optional string user_agent = 4; repeated string expids = 5; optional string os = 8; optional string model = 9; optional string brand = 11; } message UserPreference { optional Favorites favorites = 1; optional Empty UP4 = 4; optional Empty UP25 = 25; } message Favorites { optional string lang = 9; repeated Empty favorites_data = 22; } message GetMethod { optional Empty params = 1; optional HttpHeader headers = 2; optional string path = 3; } message HttpHeader { repeated string name = 1; repeated string text_value = 2; repeated bytes binary_value = 4; }
基于这个 .proto,对语音输入『北京天气』的 POST 请求消息进行解码,最终的结果摘要如下:
$ more up.txt ######## Data block info: offset=0xa blockSize=3717 #### Proto: Voicesearch.VoiceSearchRequest Message with Size=3654 user_info { lang { 1: "cmn-Hans-CN" 2: 1 } locale { 1: "zh_CN" 2: 2 } uid: "******" google_now { auth_url: "https://www.googleapis.com/auth/googlenow" auth_key: "******" 7: 1 } 8: "w " } voice_sampling { sample_rate: 16000.0 3: 9 } client_info { type: "voice-search" user_agent: "Mozilla/5.0 (Linux; Android 4.4.4; ......" expids: "p2016_01_27_20_58_17" expids: "8501679" expids: "8501680" expids: "8502094" expids: "8502157" expids: "8502159" expids: "8502312" expids: "8502347" expids: "8502369" expids: "8502376" expids: "8502490" expids: "8502491" expids: "8502618" expids: "8502679" expids: "8502705" expids: "8502947" expids: "8502986" expids: "8503012" expids: "8503037" expids: "8503109" expids: "8503110" expids: "8503132" expids: "8503133" expids: "8503157" expids: "8503158" expids: "8503208" expids: "8503212" expids: "8503214" expids: "8503303" expids: "8503306" expids: "8503367" expids: "8503368" expids: "8503404" expids: "8503512" expids: "8503559" expids: "8503585" expids: "8503604" expids: "8503606" expids: "8503729" expids: "8503730" expids: "8503751" expids: "8503752" expids: "8503755" expids: "8503805" expids: "8503815" expids: "8503832" expids: "8503835" expids: "8503844" expids: "8503853" expids: "8503855" expids: "8503907" expids: "8503925" expids: "8503927" expids: "8503994" expids: "8504022" expids: "8504059" os: "Android" model: "KTU84P" brand: "HM 1S" 1: "" 10: "300601416" 12: 720 13: 1280 14: 320 17: "assistant-query-entry" } user_preference { favorites { favorites_data { 1: 2 2: "fresh" 9: "cmn-Hans-CN" } favorites_data { 1: 2 2: "favorite-phone" 9: "cmn-Hans-CN" } favorites_data { 1: 2 2: "favorite-email" 9: "cmn-Hans-CN" } favorites_data { 1: 2 2: "favorite-person" 9: "cmn-Hans-CN" } } UP4 { 1: 10 2: 250 3: 1 } UP25 { 1: 0 } 3: 5 5: 1 7: 0 13: 1 14: 1 20: 1 22: 0 } VSR27301014 { 1: 460 2: 0 3: 460 4: 0 5: 1 } VSR27423252 { 1: "rbshUd2M8t4" } VSR27801516 { d7: 0.6037593483924866 } get_method { params { 1: "noj" 1: "tch" 1: "spknlang" 1: "ar" 1: "br" 1: "ttsm" 1: "client" 1: "hl" 1: "oe" 1: "safe" 1: "gcc" 1: "ctzn" 1: "ctf" 1: "v" 1: "padt" 1: "padb" 1: "ntyp" 1: "ram_mb" 1: "qsubts" 1: "wf" 1: "inm" 1: "source" 1: "entrypoint" 1: "action" 2: "1" 2: "6" 2: "cmn-Hans-CN" 2: "0" 2: "0" 2: "default" 2: "ms-android-xiaomi" 2: "zh-CN" 2: "utf-8" 2: "images" 2: "cn" 2: "Asia/Shanghai" 2: "1" 2: "5.9.33.19.arm" 2: "200" 2: "640" 2: "1" 2: "870" 2: "1458289692542" 2: "pp1" 2: "vs-asst" 2: "and/assist" 2: "android-assistant-query-entry" 2: "devloc" } headers { name: "User-Agent" name: "X-Speech-RequestId" name: "Cookie" name: "Host" name: "Date" name: "X-Client-Instance-Id" name: "X-Geo" name: "X-Client-Opt-In-Context" name: "X-Client-Data" text_value: "Mozilla/5.0 (Linux; Android 4.4.4; ......" text_value: "rbshUd2M8t4" text_value: "******" text_value: "www.google.com.hk" text_value: "Fri, 18 Mar 2016 08:28:13 GMT" text_value: "c6aef75ce70631e9518ae8e7011bb3d7957b24d59b62fd1b9d378262b3690cff" text_value: "w CAEQDKIBBTk6MTox" binary_value: "\037\213\b\000......" binary_value: "\b\257\363\206......gsa" } path: "/search" 5: 0 } VSR61914024 { 1: 1 } VSR77499489 { 1: 1 } VSR82185720 { 1: "\b\001" } 1: "voicesearch-web" 2: 1 4: 0 ######## Data block info: offset=0xe93 blockSize=39 #### Proto: Voicesearch.VoiceSearchRequest Message with Size=39 get_method { headers { name: "X-Geo" text_value: "w CAEQDKIBBTE6MTox" 3: 2 } } 2: 1 ######## Data block info: offset=0xebe blockSize=309 #### Proto: Voicesearch.VoiceSearchRequest Message with Size=309 voice_data { amr_stream: "#!AMR-WB......" } 2: 1 ######## Data block info: offset=0xff7 blockSize=309 #### Proto: Voicesearch.VoiceSearchRequest Message with Size=309 voice_data { amr_stream: "......" } 2: 1 ...... ######## Data block info: offset=0x4394 blockSize=309 #### Proto: Voicesearch.VoiceSearchRequest Message with Size=309 voice_data { amr_stream: "......" } 2: 1 ######## Data block info: offset=0x44cd blockSize=267 #### Proto: Voicesearch.VoiceSearchRequest Message with Size=267 voice_data { amr_stream: "......" } 2: 1 ######## Data block info: offset=0x45dc blockSize=4 #### Proto: Voicesearch.VoiceSearchRequest Message with Size=4 fin_stream: 1 2: 1
语音搜索 down 连接
在 down 连接中,Google App 仅利用了 POST 响应通路的 chunked 编码流式下发识别结果、搜索结果和语音播报数据。App 发起的虽然是一个 POST 请求,但在请求中并没有任何 POST Data,Content-Length 为 0。响应消息的 header 如下:
Content-Type application/vnd.google.octet-stream-compressible Content-Disposition attachment Cache-Control no-transform X-Content-Type-Options nosniff Pragma no-cache Content-Encoding gzip Date Fri, 18 Mar 2016 08:28:14 GMT Server S3 v1.0 X-XSS-Protection 1; mode=block X-Frame-Options SAMEORIGIN Alternate-Protocol 443:quic,p=1 Alt-Svc quic=":443"; ma=2592000; v="31,30,29,28,27,26,25" Transfer-Encoding chunked
这里值得注意的,仍然是 Cotent-Type,这次是 application/vnd.google.octet-stream-compressible,又一个二进制私有协议数据流。通过与 up 流分析类似的方法,发现 Google App 语音搜索 down 连接的响应消息封包格式大致如下:
(4 bytes Header) (Big Endian Fixed32 of Msg Len)(Msg) (Big Endian Fixed32 of Msg Len)(Msg) ...... (Big Endian Fixed32 of Msg Len)(Msg)
可以看到,除了 Header 长度不同以外,基本与 up 连接的请求消息封包格式一样。采取同样的方式推测到 Google 语音搜索响应消息的 .proto 可能是这样的:
$ more voicesearch.proto package com.google.search.app; message VoiceSearchResponse { optional int32 fin_stream = 1; optional RecogBlock recog_block = 1253625; optional SearchResult search_result = 39442181; optional TtsSound tts_sound = 28599812; } message RecogBlock { optional RecogResult recog_result = 1; optional VoiceRecording voice_recording = 2; optional string inputLang = 3; optional string searchLang = 4; } message RecogResult { optional CandidateResults can = 3; repeated RecogSegment recog_segment = 4; optional CandidateResults embededi15 = 5; } message VoiceRecording { optional int32 record_interval = 3; } message RecogSegment { optional DisplayResult display = 1; optional int32 seg_time = 3; } message DisplayResult { optional string query = 1; optional double prob = 2; } message CandidateResults { optional CandidateResult can3 = 3; optional Empty can4 = 4; } message CandidateResult { repeated string query = 1; repeated string queryWords = 12; optional float res2 = 2; repeated CandidateInfo res7 = 7; } message CandidateInfo { optional CandidateInfoMore inf1 = 1; } message CandidateInfoMore { optional CandidateInfoMoreDetial more1 = 1; optional string more3 = 3; } message CandidateInfoMoreDetial { optional string type = 1; optional string detial2 = 2; optional float detial3 = 3; optional CandidateInfoMoreDetialSnip detial7 = 7; optional string detial8 = 8; } message CandidateInfoMoreDetialSnip { optional string query = 1; } message SearchResult { optional string header = 1; optional bytes bodyBytes = 3; } message TtsSound { optional bytes sound_data = 1; optional int32 fin_stream = 2; optional Empty code_rate = 3; } message Empty { }
基于这个 .proto,对语音输入『北京天气』的 down 响应消息进行解码,最终的结果摘要如下:
$ more down.txt ######## Raw Data block info: offset=0x4 size=41 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=41 recog_block { voice_recording { record_interval: 140000 1: 0 2: 0 } inputLang: "cmn-hans-cn" searchLang: "cmn-Hans-CN" } ######## Raw Data block info: offset=0x31 size=61 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=61 recog_block { recog_result { recog_segment { display { query: "北" prob: 0.01 } seg_time: 1500000 2: 0 } 1: 0 2: 0 } inputLang: "cmn-hans-cn" searchLang: "cmn-Hans-CN" } ######## Raw Data block info: offset=0x72 size=64 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=64 recog_block { recog_result { recog_segment { display { query: "北京" prob: 0.01 } seg_time: 1560000 2: 0 } 1: 0 2: 0 } inputLang: "cmn-hans-cn" searchLang: "cmn-Hans-CN" } ######## Raw Data block info: offset=0xb6 size=67 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=67 recog_block { recog_result { recog_segment { display { query: "北京天" prob: 0.01 } seg_time: 1980000 2: 0 } 1: 0 2: 0 } inputLang: "cmn-hans-cn" searchLang: "cmn-Hans-CN" } ######## Raw Data block info: offset=0xfd size=71 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=71 recog_block { recog_result { recog_segment { display { query: "北京天气" prob: 0.01 } seg_time: 2100000 2: 0 } 1: 0 2: 0 } inputLang: "cmn-hans-cn" searchLang: "cmn-Hans-CN" } ######## Raw Data block info: offset=0x148 size=71 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=71 recog_block { recog_result { recog_segment { display { query: "北京天气" prob: 0.9 } seg_time: 2700000 2: 0 } 1: 0 2: 0 } inputLang: "cmn-hans-cn" searchLang: "cmn-Hans-CN" } ######## Raw Data block info: offset=0x193 size=14 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=14 recog_block { voice_recording { 1: 1 2: 2100000 } } ######## Raw Data block info: offset=0x1a5 size=40 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=40 recog_block { voice_recording { 1: 2 2: 3570000 } inputLang: "cmn-hans-cn" searchLang: "cmn-Hans-CN" } ######## Raw Data block info: offset=0x1d1 size=491 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=484 recog_block { recog_result { can { can3 { query: "北京天气" query: "北京天気" query: "北京天器" res2: 0.9156357 queryWords: "北 京 天 气" queryWords: "北 京 天 気" queryWords: "北 京 天 器" 6: "\020\n\030\001" } 1: 0 2: 3570000 } embededi15 { can3 { query: "北京天气" query: "北京天気" query: "北京天器" res2: 0.9156357 res7 { inf1 { more1 { type: "literal" detial2: "北京天气" detial3: 1.0 detial7 { query: "北京天气" 2: 0 3: 75 } detial8: "北 京 天 气" } more3: "" } } res7 { inf1 { more1 { type: "literal" detial2: "北京天気" detial3: 1.0 detial7 { query: "北京天気" 2: 0 3: 75 } detial8: "北 京 天 気" } more3: "" } } res7 { inf1 { more1 { type: "literal" detial2: "北京天器" detial3: 1.0 detial7 { query: "北京天器" 2: 0 3: 75 } detial8: "北 京 天 器" } more3: "" } } queryWords: "北 京 天 气" queryWords: "北 京 天 気" queryWords: "北 京 天 器" } 1: 0 2: 3570000 } 1: 1 2: 0 } inputLang: "cmn-hans-cn" searchLang: "cmn-Hans-CN" } ######## Raw Data block info: offset=0x3c0 size=29759 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=29759 search_result { header: "HTTP/1.1 200 OK Content-Type: application/x-protobuffer Date: Fri, 18 Mar 2016 08:28:17 GMT Expires: -1 Cache-Control: no-store Set-Cookie: ****** Trailer: X-Google-GFE-Current-Request-Cost-From-GWS Set-Cookie: ****** Content-Disposition: attachment; filename=\"f.txt\" " bodyBytes: "\301R\n\026IbzrVsTgFsK0jwP1iY34CQ .... charset=UTF-8H\001" 2: 1 4: 0 } ## Bodybytes Proto: Textsearch.SearchResponse with Size=10561 search_id: "IbzrVsTgFsK0jwP1iY34CQ" msg_type: 97000 result { fin_stream: 0 text_data: "北京天气" html_data: "<!doctype html><html ......</script>" encoding: "text/html; charset=UTF-8" 9: 1 } ## Bodybytes Proto: Textsearch.SearchResponse with Size=16951 search_id: "IbzrVsTgFsK0jwP1iY34CQ" result { fin_stream: 0 html_data: "<style data-jiis=\"cc\" ......</script>" encoding: "text/html; charset=UTF-8" 9: 1 } ## Bodybytes Proto: Textsearch.SearchResponse with Size=1511 ...... ######## Raw Data block info: offset=0x7803 size=81 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=81 search_result { bodyBytes: "D\n\026IbzrVsTgFsK0jwP1iY34CQ\......" 2: 1 4: 0 } ## Bodybytes Proto: Textsearch.SearchResponse with Size=68 ...... ######## Raw Data block info: offset=0x7858 size=107685 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=107685 ...... ## Bodybytes Proto: Textsearch.SearchResponse with Size=103014 ...... ## Bodybytes Proto: Textsearch.SearchResponse with Size=2454 ...... ## Bodybytes Proto: Textsearch.SearchResponse with Size=2194 ...... ######## Raw Data block info: offset=0x21d01 size=8480 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=8480 ...... ## Bodybytes Proto: Textsearch.SearchResponse with Size=7260 ...... ## Bodybytes Proto: Textsearch.SearchResponse with Size=1202 ...... ######## Raw Data block info: offset=0x23e25 size=1615 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=1615 tts_sound { sound_data: "\377\363@\304\......" code_rate { 1: 22050 } } ######## Raw Data block info: offset=0x24478 size=1609 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=1609 tts_sound { sound_data: "k\224ed4= \213......" } ######## Raw Data block info: offset=0x24ac5 size=1609 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=1609 ...... ######## Raw Data block info: offset=0x25112 size=1609 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=1609 ...... ######## Raw Data block info: offset=0x2575f size=1609 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=1609 ...... ######## Raw Data block info: offset=0x25dac size=1518 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=1518 ...... ######## Raw Data block info: offset=0x2639e size=7 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=7 tts_sound { fin_stream: 1 } ######## Raw Data block info: offset=0x263a9 size=2 #### Proto: Voicesearch.VoiceSearchResponse Message with Size=2 fin_stream: 1
协议分析和启发
针对以上解码结果,对 Google App 语音搜索协议分析如下:
双向流式通信模式
上传音频采用流式方式,这个并不难想,但是识别结果、搜索结果和语音播报音频流全部在一个流里回传,需要做很多复杂的架构升级工作,带来的收益也是很明显的:
- 识别结果实时上屏。语音识别在解码时,需要收集到足够的语音流之后,才能识别出来文字,也就是说文字结果的出现时间比较随机。流式的识别结果下发能实现一旦有识别中间结果,就可以用最快的速度下发到 App 端展示给用户。
- 减少一次搜索请求开销。从逻辑上讲,应该先进行语音识别,再用识别出的 Query 发起搜索。但 Google 把这一步放在了服务端,不需要用户再发起一次搜索结果的 GET 请求。因为 Google Search 的域名和 locale 有关,新的 GET 请求可能需要发给 google.com.hk,这就需要 App 新建一个 HTTPS 连接,开销还是比较大的。而且服务器端还可以进一步优化,比如在识别出中间结果的同时请求即时搜索,不知道 Google 有没有做。
- 减少一次语音播报请求开销。原理同上
- 提升了 TCP 信道利用率。在同等传输数据量下,减少网络连接数,受 TCP 拥塞控制策略影响,TCP 信道的传输性能能得到一定提升。
录音小块回传
从 up 解码结果来看,Google App 音频流的是以固定每 300 字节一个封包,基于 AMR-WB 压缩这基本相当于 100ms 左右的录音。说明录音数据的上传还是很频繁的,这也能够让服务端尽早地识别出中间结果。还有就是,300字节的设计比较容易 fit in 1500 左右的以太网 MTU、576 的 3G、4G MTU。
搜索结果内置
搜索结果内置,其实相当于在服务器端『代理』App 发起一次内网搜索请求,那服务器端必须要知道正常的 App 请求参数是怎样的。所以在 App 端发起语音请求时,首先向服务器端传送了一些 App 端的配置信息,比如语言、地域、终端类型、用户偏好、搜索参数、搜索 Header 等。有了这些信息,服务器端就可以伪装成 App,通过内网发起搜索请求,获取搜索结果然后把结果内置到语音搜索结果里。但注意到中文语音搜索和文本搜索的域名不同,分别是 google.com 和 google.com.hk,在机房部署上如何高效地实现全球机房的高效内网访问,这仍然是个架构难题,这里无法窥知答案。
对文本搜索结果的分析我们了解到,Google App 的搜索结果是用 Protobuf Message 数组的方式下传的。但语音搜索并没有使用与文本搜索相同的数组元素 Message,所以文本搜索结果是以 bytes 方式,将序列化后的文本搜索 Message 放到语音搜索 Message 中的一个字段中。这也许是为了协议解耦,避免单方面协议改动带来的同步问题。但可能是基于效率考虑,多个文本搜索 Message 可能会被放到同一个语音搜索 Message 中,也是以 Dilimited List 的方式序列化后放入。语音搜索模块解码出来 bytes 之后,可以直接塞给文本搜索的渲染模块渲染,与文本搜索接收的协议格式完全相同。
语音播报
语音播报的音频也是以流式下传,这样可以边播边收,也有效率优势。