Python學習之路23-文本和字節序列

本篇主要講述不同編碼之間的轉換問題,比較繁雜,如果平時處理文本不多,或者語言比較單一,沒有多語言文本處理的需求,則可以略過此篇。

1. 前言

本篇主要講述Python對文本字符串的處理。主要內容如下:

  • 字符集基本概念以及Unicode;

  • Python中的字節序列;

  • Python對編碼錯誤的處理以及BOM;

  • Python對文本文件的編解碼,以及對Unicode字符的比較和排序,而這便是本篇的主要目的

  • 雙模式API和Unicode數據庫

如果對字符編碼很熟悉,也可直接跳過第2節。

2. 字符集相關概念

筆者在初學字符集相關內容的時候,對這個概念並沒有什麼疑惑:字符集嘛,就是把我們日常使用的字符(漢子,英文,符號,甚至表情等)轉換成為二進制嘛,和摩斯電碼本質上沒啥區別,用數學的觀點就是一個函數變換,這有什麼好疑惑的?直到後來越來也多地接觸字符編碼,終於,筆者被這堆概念搞蒙了:一會兒Unicode編碼,一會兒又Unicode字符集,UTF-8編碼,UTF-16字符集還有什麼字符編碼、字節序列。到底啥時候該叫“編碼”,啥時候該叫“字符集”?這些概念咋這麼相似呢?既然這麼相似,幹嘛取這麼多名字?後來仔細研究後發現,確實很多學術名次都是同義詞,比如“字符集”和“字符編碼”其實就是同義詞;有的譯者又在翻譯外國的書的時候,無意識地把一個概念給放大或者給縮小了。

說到這不得不吐槽一句,我們國家互聯網相關的圖書質量真的低。國人自己寫的IT方面的書,都不求有多經典,能稱為好書的都少之又少;而翻譯的書,要麼翻譯得晦澀難懂,還不如直接看原文;要麼故作風騷,非得體現譯者的文學修養有多“高”;要麼生造名詞,同一概念同一單詞,這本書裡你翻譯成這樣,另一本書裡我就偏要翻譯成那樣(你們這是在翻譯小說嗎)。所以勸大家有能力的話還是直接看原文吧,如果要買譯本,還請大家認真比較比較,否則讀起來真的很痛苦。

回到主題,我們繼續討論字符集相關問題。翻閱網上大量資料,做出如下總結。

2.1 基本概念

始終記住編碼的核心思想:就是給每個字符都對應一個二進制序列,其他的所有工作都是讓這個過程更規範,更易於管理。

現代編碼模型將這個過程分了5個層次,所用的術語列舉如下(為了避免混淆,這裡不再列出它們的同義詞):

  1. 抽象字符表(Abstract character repertoire):

    系統支持的所有抽象字符的集合。可以簡單理解為人們使用的文字、符號等。

    這裡需要注意一個問題:有些語系裡面的字母上方或者下方是帶有特殊符號的,比如一點或者一撇;有的字符表裡面會將字母和特殊符號組合成一個新的字符,為它單獨編碼;有的則不會單獨編碼,而是字母賦予一個編碼,特殊符號賦予一個編碼,然後當這倆在文中相遇的時候再將這倆的編碼組合起來形成一個字符。後面我們會談到這個問題,這也是以前字符編碼轉換常出毛病的一個原因。

    提醒:雖然這裡扯到了編碼,但抽象字符表這個概念還和編碼沒有聯繫。

  2. 編碼字符集(Coded Character Set,CCS):字符 –> 碼位

    首先給出總結:編碼字符集就是用數字代替抽象字符集中的每一個字符!

    將抽象字符表中的每一個字符映射到一個座標(整數值對:(x, y),比如我國的GBK編碼)或者表示為一個非負整數N,便生成了編碼字符集。與之相應的還有兩個抽象概念:編碼空間(encoding space)、碼位(code point)和碼位值(code point value)。

    簡單的理解,編碼空間就相當於許多空位的集合,這些空位稱之為碼位,而這個碼位的座標通常就是碼位值。我們將抽象字符集中的字符與碼位一一對應,然後用碼位值來代表字符。以二維空間為例,相當於我們有一個10萬行的表,每一行相當於一個碼位,二維的情況下,通常行號就是碼位值(當然你也可以設置為其他值),然後我們把每個漢字放到這個表中,最後用行號來表示每一個漢字。一個編碼字符集就是把抽象字符映射為碼位值。這裡區分碼位和碼位值只是讓這個映射的過程更形象,兩者類似於座位和座位號的區別,但真到用時,並不區分這兩者,以下兩種說法是等效的:

    “字符A的碼位是123456 == “字符A的碼位值是123456

    編碼空間並不只能是二維的,它也可以是三維的,甚至更高,比如當你以二維座標(x, y)來編號字符,並且還對抽象字符集進行了分類,那麼此時的編碼空間就可能是三維的,z座標表示分類,最終使用(x, y, z)在這個編碼空間中來定位字符。不過筆者還沒真見過(或者見過但不知道……)三維甚至更高維的編碼,最多也就見過變相的三維編碼空間。但編碼都是人定的,你也可以自己定一個編碼規則~~

    並不是每一個碼位都會被使用,比如我們的漢字有8萬多個,用10萬個數字來編號的話還會剩餘1萬多個,這些剩餘的碼位則留作擴展用。

    注意:到這一步我們只是將抽象字符集進行了編號,但這個編號並不一定是二進制的,而且它一般也不是二進制的,而是10進制或16進制。該層依然是個抽象層。

    而這裡之所以說了這麼多,就是為了和下面這個概念區分。

  3. 字符編碼表(Character Encoding Form,CEF):碼位 –> 碼元

    將編碼字符集中的碼位轉換成有限比特長度的整型值的序列。這個整型值的單位叫碼元(code unit)。即一個碼位可由一個或多個碼元表示。而這個整型值通常就是碼位的二進制表示。

    到這裡才完成了字符到二進制的轉換。程序員的工作通常到這裡就完成了。但其實還有後續兩步。

    注意:直到這裡都還沒有將這些序列存到存儲器中!所以這裡依然是個抽象,只是相比上面兩步更具體而已。

  4. 字符編碼方案(Character Encoding Scheme,CES):碼元 –> 序列化

    也稱為“serialization format”(常說的“序列化”)。將上面的整型值轉換成可存儲或可傳輸8位字節序列。簡單說就是將上面的碼元一個字節一個字節的存儲或傳輸。每個字節裡的二進制數就是字節序列。這個過程中還會涉及大小端模式的問題(碼元的低位字節裡的內容放在內存地址的高位還是低位的問題,感興趣的請自行查閱,這裡不再贅述)。

    直到這時,才真正完成了從我們使用的字符轉換到機器使用的二進制碼的過程。 抽象終於完成了實例化。

  5. 傳輸編碼語法(transfer encoding syntax):

    這裡則主要涉及傳輸的問題,如果用計算機網絡的概念來類比的話,就是如何實現透明傳輸。相當於將上面的字節序列的值映射到一個更受限的值域內,以滿足傳輸環境的限制。比如Email的Base64或quoted-printable協議,Base64是6bit作為一個單位,quoted-printable是7bit作為一個單位,所以我們得想辦法把8bit的字節序列映射到6bit或7bit的單位中。另一個情況則是壓縮字節序列的值,如LZW或進程長度編碼等無損壓縮技術。

綜上,整個編碼過程概括如下:

字符 –> 碼位 –> 碼元 –> 序列化,如果還要在特定環境傳輸,還需要再映射。從左到右是編碼的過程,從右到左就是解碼的過程。

下面我們以Unicode為例,來更具體的說明上述概念。

2.2 統一字符編碼Unicode

每個國家每個地區都有自己的字符編碼標準,如果你開發的程序是面向全球的,則不得不在這些標準之間轉換,而許多問題就出在這些轉換上。Unicode的初衷就是為了避免這種轉換,而對全球各種語言進行統一編碼。既然都在同一個標準下進行編碼,那就不存在轉換的問題了唄。但這只是理想,至今都沒編完,所以還是有轉換的問題,但已經極大的解決了以前的編碼轉換的問題了。

Unicode編碼就是上面的編碼字符集CCS。而與它相伴的則是經常用到的utf-8,utf-16等,這些則是上面的字符編碼表CEF。

最新版的Unicode庫已經收錄了超過10萬個字符,它的碼位一般用16進製表示,並且前面還要加上“U+”,十進制表示的話則是前面加“”,例如字母“A”的Unicode碼位是“U+0041”,十進制表示為“A”。

Unicode目前一共有17個Plane(面),從U+0000到U+10FFFF,每個Plane包含65536(=2^16^)個碼位,比如英文字符集就在0號平面中,它的範圍是U+0000 ~ U+FFFF。這17個Plane中4號到13號都還未使用,而15、16號Plane保留為私人使用區,而使用的5個Plane也並沒有全都用完,所以Unicode還沒有很大的未編碼空間,相當長的時間內夠用了。

注意:自2003年起,Unicode的編碼空間被規範為了21bit,但Unicode編碼並沒有佔多少位之說,而真正涉及到在存儲器中佔多少位時,便到了字符編碼階段,即utf-8,utf-16,utf-32等,這些字符編碼表在編程中也叫做編解碼器

utf-n表示用n位作為碼元來編碼Unicode的碼位。以utf-8為例,它的碼元是1字節,且最多用4個碼元為Unicode的碼位進行編碼,編碼規則如下表所示:

Python學習之路23-文本和字節序列

表中的“×”用Unicode的16進制碼位的2進制序列從右向左依次替換,比如U+07FF的二進制序列為 :00000,11111,111111(這裡的逗號位置只是為了和後面作比較,並不是正確的位置);

那麼U+07FF經utf-8編碼後的比特序列則為 110 11111,10 111111,暫時將這個序列命名為a。

至此已經完成了前3步工作,現在開始執行序列化:

如果CPU是大端模式,那麼序列a就是U+07FF在機器中的字節序列,但如果是小端模式,序列a的這兩個字節需要調換位置,變為10 111111,110 11111,這才是實際的字節序列。

3. Python中的字節序列

Python3明確區分了人類可讀的字符串和原始的字節序列。Python3中,文本總是Unicode,由str類型表示,二進制數據由bytes類型表示,並且Python3不會以任何隱式的方式混用str和bytes。Python3中的str類型基本相當於Python2中的unicode類型。

Python3內置了兩種基本的二進制序列類型:不可變bytes類型和可變bytearray類型。這兩個對象的每個元素都是介於0-255之間的整數,而且它們的切片始終是同一類型的二進制序列(而不是單個元素)。

以下是關於字節序列的一些基本操作:

Python學習之路23-文本和字節序列

二進制序列實際是整數序列,但在輸出時為了方便閱讀,將其進行了轉換,以”b“開頭,其餘部分:

  • 可打印的ASCII範圍內的字節,使用ASCII字符本身;

  • 製表符、換行符、回車符和 \ 對應的字節,使用轉義序列 \t,\n,\r 和 \\;

  • 其他字節的值,使用十六進制轉義序列,以 \\x開頭。

bytes和bytesarray的構造方法如下:

  • 一個str對象和一個encoding關鍵字參數;

  • 一個可迭代對象,值的範圍是range(256);

  • 一個實現了緩衝協議的對象(如bytes,bytearray,memoryview,array.array),此時它將源對象中的字節序列複製到新建的二進制序列中。並且,這是一種底層操作,可能涉及類型轉換。

除了格式化方法(format和format_map)和幾個處理Unicode數據的方法外,bytes和bytearray都支持str的其他方法,例如 bytes. endswith,bytes.replace等。同時,re模塊中的正則表達式函數也能處理二進制序列(當正則表達式編譯自二進制序列時會用到)。

二進制序列有個str沒有的方法fromhex,它解析十六進制數字對,構件二進制序列:

Python學習之路23-文本和字節序列

補充:struct模塊提供了一些函數,這些函數能把打包的字節序列轉換成

不同類型字段組成的元組,或者相反,把元組轉換成打包的字節序列。struct模塊能處理bytes、bytearray和memoryview對象。這個不是本篇重點,不再贅述。

4. 編解碼器問題

如第2節所述,我們常說的UTF-8,UTF-16實際上是字符編碼表,在編程中一般被稱為編解碼器。本節主要講述關於編解碼器的錯誤處理:UnicodeEncodeError,UnicodeDecodeError和SyntaxError。

Python中一般會明確的給出某種錯誤,而不會籠統地拋出UnicodeError,所以,在我們自行編寫處理異常的代碼時,也最好明確錯誤類型。

4.1 UnicodeEncodeError

當從文本轉換成字節序列時,如果編解碼器沒有定義某個字符,則有可能拋出UnicodeEncodeError。

Python學習之路23-文本和字節序列

可以指定錯誤處理方式:

Python學習之路23-文本和字節序列

4.2 UnicodeDecodeError

相應的,當從字節序列轉換成文本時,則有可能發生UnicodeDecodeError。

Python學習之路23-文本和字節序列

4.3 SyntaxError

補充:Python3允許在源碼中使用非ASCII標識符,也就是說,你可以用中文來命名變量(笑。。。)。如下:

Python學習之路23-文本和字節序列

但是極不推薦!還是老老實實用英文吧,哪怕拼音也行。

4.4 找出字節序列的編碼

有時候一個文件並沒有指明編碼,此時該如何確定它的編碼呢?實際並沒有100%確定編碼類型的方法,一般都是靠試探和分析找出編碼。比如,如果b’\\x00’字節經常出現,就很有可能是16位或32位編碼,而不是8位編碼。Chardet就是這樣工作的。它是一個Python庫,能識別所支持的30種編碼。以下是它的用法,這是在終端命令行中,不是在Python命令行中:

Python學習之路23-文本和字節序列

4.5 字節序標記BOM(byte-order mark)

當使用UTF-16編碼時,字節序列前方會有幾個額外的字節,如下:

Python學習之路23-文本和字節序列

BOM用於指明編碼時使用的是大端模式還是小端模式,上述例子是小端模式。UTF-16在要編碼的文本前面加上特殊的不可見字符ZERO WIDTH NO-BREAK SPACE(U+FEFF)。UTF-16有兩個變種:UTF-16LE,顯示指明使用小端模式;UTF-16BE,顯示指明大端模式。如果顯示指明瞭模式,則不會生成BOM:

Python學習之路23-文本和字節序列

根據標準,如果文件使用UTF-16編碼,且沒有BOM,則應假定它使用的是UTF-16大端模式編碼。然而Intel x86架構用的是小端模式,因此很多文件用的是不帶BOM的小端模式UTF-16編碼。這就容易造成混淆,如果把這些文件直接用在採用大端模式的機器上,則會出問題(比較老的AMD也有大端模式,現在的AMD也是x86架構了)。

由於大小端模式(字節順序)只對一個字(word)佔多個字節的編碼有影響,所以對於UTF-8來說,不管設備使用哪種模式,生成的字節序列始終一致,因此不需要BOM。但在Windows下就比較扯淡了,有些應用依然會添加BOM,並且會根據有無BOM來判斷是不是UTF-8編碼。

補充:筆者查資料時發現有“顯示指明BOM”一說,剛看到的時候筆者以為是在函數中傳遞一個bom關鍵字參數來指明BOM,然而不是,而是傳入一個帶有BOM標識的編解碼器,如下:

Python學習之路23-文本和字節序列

5. 處理文本文件

處理文本的最佳實踐是”Unicode三明治”模型。圖示如下:

Python學習之路23-文本和字節序列

此模型的意思是:

  1. 對輸入的字節序列應儘早解碼為字符串;

  2. 第二層相當於程序的業務邏輯,這裡應該

    保證只處理字符串,而不應該有編碼或解碼的操作存在;

  3. 對於輸出,應盡晚地把字符串編碼為字節序列

當我們用Python處理文本時,我們實際對這個模型並沒有多少感覺,因為Python在讀寫文件時會為我們做必要的編解碼工作,我們實際處理的是這個三明治的中間層。

5.1 Python編解碼

Python中調用open函數打開文件時,默認使用的是編解碼器與平臺有關,如果你的程序將來要跨平臺,推薦的做法是明確傳入encoding關鍵字參數。其實不管跨不跨平臺,這都是推薦的做法。

對於open函數,當以二進制模式打開文件時,它返回一個BufferedReader對象;當以文本模式打開文件時,它返回的是一個TextIOWrapper對象:

Python學習之路23-文本和字節序列

這裡有幾個點

  • 除非想判斷編碼方式,或者文件本身就是二進制文件,否則不要以二進制模式打開文本文件;就算想判斷編碼方式,也應該使用Chardet,而不是重複造輪子。

  • 如果打開文件時未傳入encoding參數,默認值將由locale.getpreferredencoding()提供,但從這麼函數名可以看出,其實它返回的也不一定是系統的默認設置,而是用戶的偏好設置。用戶的偏好設置在不同系統中不一定相同,而且有的系統還沒法設置偏好,所以,正如官方文檔所說,該函數返回的是一個猜測的值;

  • 如果設定了PYTHONENCODING環境變量,sys.stdout/stdin/stderr的編碼則使用該值,否則繼承自所在的控制檯;如果輸入輸出重定向到文件,編碼方式則由locale.getpreferredencoding()決定;

  • Python讀取文件時,對文件名(不是文件內容!)的編解碼器由sys.getfilesystemencoding()函數提供,當以字符串作為文件名傳入open函數時就會調用它。但如果傳入的文件名是字節序列,則會直接將此字節序列傳給系統相應的API。

總之:別依賴默認值

如果遵循Unicode三明治模型,並且始終在程序中指定編碼,那將避免很多問題。但Unicode也有不盡人意的地方,比如文本規範化(為了比較文本)和排序。如果你只在ASCII環境中,或者語言環境比較固定單一,那麼這兩個操作對你來說會很輕鬆,但如果你的程序面向多語言文本,那麼這兩個操作會很繁瑣。

5.2 規範化Unicode字符串

由於Unicode有組合字符,所以字符串比較起來比較複雜。

補充:組合字符指變音符號和附加到前一個字符上的記號,打印時作為一個整體。

Python學習之路23-文本和字節序列

在Unicode標準中,’é’和’e\\u0301’叫做標準等價物,應用程序應該將它們視為相同的字符,但從上面代碼可以看出,Python並沒有將它們視為等價物,這就給Python中比較兩個字符串添加了麻煩。

解決的方法是使用unicodedata.normalize函數提供的Unicode規範化。它有四個標準:NFC,NFD,NFKC,NFKD。

5.2.1 NFC和NFD

NFC使用最少的碼位構成等價的字符串,NFD把組合字符分解成基字符和單獨的組合字符。這兩種規範化方法都能讓比較行為符合預期:

Python學習之路23-文本和字節序列

NFC是W3C推薦的規範化形式。西方鍵盤通常能輸出組合字符,因此用戶輸入的文本默認是NFC形式。我們對變音字符用的不多。但還是那句話,如果你的程序面向多語言文本,為了安全起見,最好還是用normalize(”NFC“, user_text)清洗字符串

使用NFC時,有些單字符會被規範成另一個單字符,例如電阻的單位歐姆(Ω,U+2126,\\u2126)會被規範成希臘字母大寫的歐米伽(U+03A9, \\u03a9)。這倆看著一樣,現實中電阻歐姆的符號也就是從希臘字母來的,兩者應該相等,但在Unicode中是不等的,因此需要規範化,防止出現意外。

5.2.2 NFKC和NFKD

NFKC和NFKD(K表示“compatibility”,兼容性)是比較嚴格的規範化形式,對“兼容字符”有影響。為了兼容現有的標準,Unicode中有些字符會出現多次。比如希臘字母’μ’(U+03BC),Unicode除了有它,還加入了微符號’µ’(U+00B5),以便和latin1標準相互轉換,所以微符號是個“兼容字符”(上述的歐姆符號不是兼容字符!)。這兩個規範會將兼容字符分解為一個或多個字符,如下:

Python學習之路23-文本和字節序列

從上面的代碼可以看出,這兩個標準可能會造成格式損失,甚至曲解信息,但可以為搜索和索引提供便利的中間表述。比如用戶在搜索 ’1/2 inch‘ 時,可能還會搜到包含’½ inch’的文章,這便增加了匹配選項。

5.2.3 大小寫摺疊

對於搜索或索引,大小寫是個很有用的操作。同時,對於Unicode來說,大小寫摺疊還是個複雜的問題。對於此問題,如果是初學者,首先想到的一定是str.lower()和str.upper()。但在處理多語言文本時,str.casefold()更常用,它將字符轉換成小寫。自Python3.4起,str.casefold()和str.lower()得到不同結果的有116個碼位。對於只包含latin1字符的字符串s,s.casefold()得到的結果和s.lower()一樣,但有兩個例外:微符號’µ’會變為希臘字母’μ’;德語Eszett(“sharp s”,ß)為變成’ss’。

5.2.4 規範化文本匹配使用函數

下面給出用以上內容編寫的幾個規範化匹配函數。對大多數應用來說,NFC是最好的規範形式。不區分大小寫的比較應該使用str.casefold()。對於處理多語言文本,以下兩個函數應該是必不可少的:

Python學習之路23-文本和字節序列

有時我們還想把變音符號去掉(例如“café”變“cafe”),比如谷歌在搜索時就有可能去掉變音符號;或者想讓URL更易讀時,也需要去掉變音符號。如果想去掉文本中的全部變音符號,則可用如下函數:

Python學習之路23-文本和字節序列

上述代碼去掉了所有的變音字符,包括非拉丁字符,但有時我們想只去掉拉丁字符中的變音字符,為此,我們還需要對基字符進行判斷,以下這個版本只去掉拉丁字符中的變音字符:

Python學習之路23-文本和字節序列

注意<1>處,如果一開始直接 latin_base = False,那麼遇到刁鑽的人,該程序的結果將是錯誤的:大家可以試一試,把<1>處改成 latin_base = False,然後運行該程序,看c上面的變音符號去掉了沒有。之所以第7行寫成上述形式,就是考慮到可能有的人閒著沒事,將變音符號放在字符串的開頭。

更徹底的規範化步驟是把西文中的常見符號替換成ASCII中的對等字符,如下:

Python學習之路23-文本和字節序列

5.3 Unicode文本排序

Python中,非ASCII文本的標準排序方式是使用locale.strxfrm函數,該函數“把字符串轉換成適合所在地區進行比較的形式”,即和系統設置的地區相關。在使用locale.strxfrm之前,必須先為應用設置合適的區域,而這還得指望著操作系統支持用戶自定義區域設置。比如以下排序:

Python學習之路23-文本和字節序列

筆者是Windows系統,不支持區域設置,不知道Linux下支不支持,大家可以試試。

5.3.1 PyUCA

想要正確實現Unicode排序,可以使用PyPI中的PyUCA庫,這是Unicode排序算法的純Python實現。它沒有考慮區域設置,而是根據Unicode官方數據庫中的排序表排序,只支持Python3。以下是它的簡單用法:

Python學習之路23-文本和字節序列

如果想定製排序方式,可把自定義的排序表路徑傳給Collator()構造方法。

6. 補充

6.1 Unicode數據庫

Unicode標準提供了一個完整的數據庫(許多格式化的文本文件),它記錄了字符是否可打印、是不是字母、是不是數字、或者是不是其它數值符號等,這些數據叫做字符的元數據。字符串中的isidentifier、isprintable、isdecimal和isnumeric等方法都用到了該數據庫。unicodedata模塊中有幾個函數可用於獲取字符的元數據,比如unicodedata.name()用於獲取字符的官方名稱(全大寫),unicodedata.numeric()得到數值字符(如①,“1”)的浮點數值。

6.2 支持字符串和字節序列的雙模式API

目前為止,我們一般都將字符串作為參數傳遞給函數,但Python標準庫中有些函數既支持字符串也支持字節序列作為參數,比如re和os模塊中就有這樣的函數。

6.2.1 正則表達式中的字符串和字節序列

如果使用字節序列構建正則表達式,\d和\w等模式只能匹配ASCII字符;如果是字符串模式,就能匹配ASCII之外的Unicode數字和字母,如下:

Python學習之路23-文本和字節序列

6.2.2 os模塊中的字符串和字節序列

Python的os模塊中的所有函數、文件名或操作路徑參數既能是字符串,也能是字節序列。如下:

Python學習之路23-文本和字節序列

在Unix衍生平臺中,這些函數編解碼時使用surrogateescape錯誤處理方式以避免遇到意外字節序列時卡住。surrogateescape把每個無法解碼的字節替換成Unicode中U+DC00到U+DCFF之間的碼位,這些碼位是保留位,未分配字符,共應用程序內部使用。Windows使用的錯誤處理方式是strict。

7. 總結

本節內容較多。本篇首先介紹了編碼的基本概念,並以Unicode為例說明了編碼的具體過程;然後介紹了Python中的字節序列;隨後開始接觸實際的編碼處理,如Python編解碼過程中會引發的錯誤,以及Python中Unicode字符的比較和排序。最後,本篇簡要介紹了Unicode數據庫和雙模式API。


分享到:


相關文章: