close

跨多端開發避坑指南


前言

細想,專門從事跨多端開發已兩年有餘,前段時間因為組裡跨桌面端項目需要回歸windows下開發了整整2個月,怎麼形容這兩個月呢,嘿嘿,各種「肆無忌憚」的寫法,終於不用在寫一行代碼考慮後面n個端的行為了,"勞動力"、"效率"得到大幅度解放,但是隨着windows發版結束後,我負責mac的適配相關工作,在這個階段,發現很多不"合規"的奇技淫巧(原定2個工作日的適配quota,大概進行了一周),作為一個略有想法的cpp程序員,遂產生了想寫一個跨多端開發避坑指南的想法,想起過去看的Scott Meyers的《Effective C++》....努力寫"xx條有效使用cpp開發跨端的經驗",期望看完此文可以幫助大家在如何保持同一份cpp代碼在多個平台編譯和構建上行為一致上有一絲絲幫助。
跨多端開發下的複雜性,究其本質大多是因為兩個原因引發的
多系統下平台差異
多編譯器下行為不確定性
下面主要講解的也將從這兩個方面入手。
同時,在拜讀了多份cpp程序員開發寶典里,還是覺得 Google C++ Style Guide是最有效的,最直接的避坑寶典,依舊推薦給大家:https://google.github.io/styleguide/cppguide.html

下面進入正文——
C++VERSION的選擇

C++version選擇可以說對於跨終端開發是至關重要的,跨端開發一個比較難的點在於多平台下,如何很好的支撐平台差異點,隨着C++版本的升級,越來越多的新feature在標準庫中得到支持,這也就是意味着開發者可以更少的關注平台差異點,因此這裡建議選擇最新的穩定版本,截止到目前推薦使用C++17.

禁止在一個單獨的編譯中重複包含文件的現象出現

可以通過兩種方式有效的避免此類情況

#pragma once,需要特別注意這是一個非標準但是被廣泛支持的前置處理符號,在主流的編譯中clang,ms等均已支持。

#pragma once#include<vector>...

使用#define的方式

#ifndef FOO_BAR_BAZ_H_#define FOO_BAR_BAZ_H_...#endif // FOO_BAR_BAZ_H_
路徑和頭文件路徑分隔符的問題

在windows中路徑的識別對於正反斜槓均支持,但是在linux中,只能是/,此外,在linux中對於路徑是嚴格區分大小寫的,對於windows則忽略大小寫。

建議:

對於路徑均需嚴格保證大小寫與實際路徑的匹配

在代碼中禁止對路徑使用「\」,請用「/」代替。

此舉,將在你從win到mac適配過程中,節省大量的工作量。

C標準庫的頭文件包含

在Windows下某些C標準庫的頭文件不用顯式包含,但是在linux下需要顯式包含。因此在跨端開發中,應在.c和.cpp文件中儘量包含這個文件中需要的頭文件,並且這也是C語言標準從C99以後的標準要求。

代碼文件格式

在跨終端開發中,特別是包含中文的部分,除非你的代碼都是英文注釋,否則很難避免在多平台下(特別是windows與類unix平台下的開發)交叉開發帶來的中文亂碼問題。

建議:全部使用UTF-8 BOM編碼格式。

關於內聯函數

定義:當函數被聲明為內聯函數之後, 編譯器會將其內聯展開, 而不是按通常的函數調用機制進行調用。

參考定義,自然他的優點,在函數體比較小的情況下,內聯該函數可以令目標代碼更高效,通常情況下,應該鼓勵在函數比較短時使用內聯。

關於內聯函數,或許很多非跨端程序員或認為不足為重,其實這裡有幾個非常值得在跨端開發被重視的問題:

過度的內聯,會導致程序臃腫,特別是對於移動端,一方面c++代碼的體積問題一直不能很好的得到解決,另一方面也會使得程序變慢。

在導出頭文件中非恰當的使用內聯,會導致在跨模塊開發中帶來意向不到的結果。這裡舉個例子,在提供跨終端SDK時,通常會提供導出頭文件,但是如果在導出頭文件里不恰當的內聯,將使得編譯從當前單元跨越到另外一個模塊,可能會引發一系列問題

儘管編譯器對內聯函數都有或多或少優化,但是不同編譯器不盡相同,實踐下來良好的內聯使用習慣依舊能幫助大家,譬如,我們在移動端的某個cpp項目中,通過去內聯,減少了一定的包大小,實踐證明編譯器在擇優選擇的過程中不一定會完美契合。關於內聯的編譯器優化可以參考:https://isocpp.org/wiki/faq/inline-function

綜上在跨端開發中因儘量避免使用內聯,這裡給出幾個可以衡量的準則(經驗值?):

行數超過10行禁止使用內聯(google建議)

在非get函數裡禁止使用內聯(經驗值,這一條爭議會比較大,但在我看來只有在get某成員變量值時使用內聯是有必要的,其他都沒有必要且可能會帶來「驚喜」)

內聯函數務必要有適當的修飾符(const)

析構函數如果有自定義內容,禁止使用內聯(google建議,通常析構函數遠比你想想的做的要多)

關於基礎類型定義

請使用基礎類型定義,禁止使用自定義基礎類型。

看過團隊的幾個代碼庫,在基礎類型的使用上有些同學甚至三方庫也非常喜歡自定義,譬如

typedef std::int8_t int8; typedef std::int16_t int16; typedef std::int32_t int32; typedef std::int64_t int64; typedef std::uint8_t uint8; typedef std::uint16_t uint16; typedef std::uint32_t uint32; typedef std::uint64_t uint64;

在進行跨模塊開發以及代碼融合時,這些基礎類型的自定義經常會出現歧義,redefine等等,或許你會說這樣的定義應該要有自己的#define保護,但是大多數程序員不會這麼做,這裡強烈不建議自定義基礎類型,標準庫提供的已經足夠簡略和通用,請方便自己開發的時候同時照顧下團隊同學。

CHAR的定義

char的定義需要顯示是unsigned還是signed。

需要注意的是,char在標準中不指定為signed或unsigned,不同的編譯器可能會有不一樣的結果,在發生隱式轉換時可能會有超出期望的結果,譬如,char強轉int時,發現在x86平台下是按照有符號處理的,但是在ARM32下被當成了無符號導致問題,ARM64正常有符號,當然你可以通過指定CFLAG += fsigned-char 來解決,但是此類問題應當在規範時就被避免掉。

關於寬字符的問題

你需要知道的:在Windows中,wchar_t占兩個字節,Linux中占四個字節,這裡有幾個問題

導致體積占用大小不同。

程序移植帶來困難

隱式轉換結果不符合預期

跨端開發應避免wchar的普遍使用,以避免寬窄字符轉換帶來的開銷以及額外的問題,應普遍使用utf-8作為主要的編碼,這也是主流的思路。即時是特殊場景也可以用使用utf16,避免使用wchar。簡而言之,除非必要,否則請不要使用。

應該限定字符串數組在保存為字節流時,使用編碼為uft-8

請在字符串前加u8"", 特別是包含中文的部分,習慣在vs下開發的同學也需要額外注意,vs默認的文件編碼是gb2312, 這會有概率導致字符串可能會不小心被保存為gbk編碼格式。

同時u8僅限在字符串前使用,在字符前使用是沒有任何意義的,即時在ms上會編譯通過,在clang下會提示

int pos = targetID.rfind(u8'_'); // error: use of undeclared identifier 'u8' ...
避免連續兩個尖括號的定義

例如

std::vector<std::vector<int>> vec

在Windows下這麼寫沒問題,那麼在某些平台下可能編譯不過,提供兩種方式:

可以在連續兩個尖括號符號之間留一個空格,即

std::vector<std::vector<int> > vec;

也可以typedef


C++11標準里已經解決了此問題,如果確認編譯器版本已經支持了這個特性(參考:https://isocpp.org/wiki/faq/cpp11-language-misc
InC++98thisisasyntaxerrorbecausethereisnospacebetweenthetwo>s.C++11recognizessuchtwo>sasacorrectterminationoftwotemplateargumentlists.),此條可以忽略,但是通常兩個>>的情況也意味着嵌套使用,typedef後通常閱讀性也會得到提高。
對於平台差異的代碼部分處理

跨端開發難免出現平台差異性代碼,對於這部分的處理,對於簡短的部分建議使用if def的方式區別,對於功能性的、代碼較多的建議使用分文件開發,xxxx_win.cpp, xxxx_mac.cpp, xxxx_linux.cpp, 可以參考chromium的代碼在大量使用這種方式。

同時對於差異性代碼部分,應保持除非必要否則不定義的原則,因儘可能保持跨端的代碼處理方式,過多的平台差異性將勢必導致維護性變的很差。

應避免使用非標準的編譯器支持的關鍵詞

c++標準關鍵詞參考:https://baike.baidu.com/item/C%2B%2B%E5%85%B3%E9%94%AE%E5%AD%97/5773813

雙底槓開頭的關鍵詞多為Microsoft定義的c++關鍵詞,跨端開發中應儘量避免,諸如:__super, __wchar_t, __stdcall__stdcall等等,詳細的請參考:https://docs.microsoft.com/zh-cn/cpp/cpp/keywords-cpp?view=msvc-170#microsoft-specific-c-keywords

Assert的使用

Assert在pc時代是作為一個廣泛(甚至是爛泛)使用的警告處理方式,在移動端以及類unix系統中,debug下表現通常會比windows更加猛烈些,通常是阻塞式的處理,特別是移動端會導致程序繼續運行不下去,不像windows彈個框給你一個continue的選項。

因此在跨端開發中應避免直接使用assert,可以考慮使用重定義後的assert,同時合情合理使用重定義後的assert。

#ifdef NDEBUG#define ALOG_ASSERT(_Expression) ((void)0)#else#define ALOG_ASSERT(_Expression) do { \ ... \ 這裡可以額外做error級別日誌輸出,是否進行assert阻塞式處理。 if(HandleAssert()) \ { \ assert(_Expression); \ } \} while (false)#endif
關於繼承

Compositionisoftenmoreappropriatethaninheritance.Whenusinginheritance,makeitpublic.

google的這個定義應該還是非常準確的,通常組合比繼承更合適,即時要使用也必須是publice的方式。應儘量保持「is a」的情況下使用繼承,如果你想使用私有繼承, 你應該替換成把基類的實例作為成員對象的方式。

對於重載的虛函數或虛析構函數, 使用 override, 或(較不常用的) final 關鍵字顯式地進行標記. 在部分clang編譯器下,編譯器要求務必顯示聲明,否則會報錯,ms則沒有此類要求。

關於Static變量

感興趣的小夥伴可以研究一下c++的特性「「Dynamic Initialization and Destruction with Concurrency」,其中裡面有定義靜態、動態變量析構的順序,線程生命周期的對象全部在靜態變量之前析構,靜態變量按照後構造的先析構的棧式順序釋放。實際在實踐中發現apple的clang編譯器和運行時庫對c++11的這個特性支持,未實現靜態變量析構的多線程安全。

因此在目前階段,如果有用到全局靜態變量時需要考慮到析構多線程安全的問題,否則線上在個別平台會發生crash。

一個比較簡單的思路:從全局靜態變量替換為局部靜態變量且不釋放,直到進程被kill。這裡還有一個變相的好處:把加載時機從load變成了此代碼段真正運行時。

eg:old:static std::recursive_mutex& m_mutex;new:static std::recursive_mutex& mutex(){static std::recursive_mutex& mutex = *(new std::recursive_mutex());return mutex;}
關於模板

模板的出現極大的方便了程序員,在未進入跨終端領域之前,雖了解它的一些詬病(代碼膨脹&不合理的使用帶來的性能損耗),也一直認為是一個非常棒的feature,隨着移動端對包大小的要求越來越嚴格,模板的使用在跨終端上被限制,需要更為合理的使用,否則將膨脹的非常厲害。在漫長的去模板化過程中有些經驗值可以輸出,供大家參考。

在涉及到移動端的跨終端開發里,應儘量避免使用模板,除非它帶來足夠多的收益,比如json序列化,通篇用cjson的方式替換,從開發體驗和代碼膨脹比上來看,替換就顯得不值得,比如自定義std標準容器,看似省了不少膨脹,但是代碼的維護性和可讀性降低了很多,同樣不值得替換。

儘可能選擇小的模板編譯單元,比如原來一個模板類,改為類里的模板函數

通常情況下模板可以以各種方式被除去,這裡不是說在裸寫一遍模板換實參的方法。

應儘可能的減少模板膨脹的速度,換句話說如果有可能應該儘量限制模板被特化的可能,譬如,我們的日誌序列化,對於任意struct或者class在實現了ToString()方法後均可以實現日誌自動化輸出,任意類型在進入到LOG_IMPL中都會生成一份具體類型的實體,經過略微改造後,限制需要被序列化的類型需要顯示繼承IOBJECT的接口類,改造後,在同樣進入到LOG_IMPL中所有的類型只會有一份類型(IOBJECT*)實例化,此舉在實踐過程中大約減少了我們五分之一的包大小。

在多重繼承中,特別是公共模塊基類如果包含模板,去模板的收益一般會比較大,因儘量限制基類中出現模板,除非必要,否則應以任何方式替換。

最後再插一嘴,模板對於使用者確實是極大的方便,但是在跨終端領域似乎對於模板的構建者有着更為嚴格的要求,需要着重考慮如何避免被膨脹,此外對於性能的要求也更為嚴格,c++11里有不少提供模板性能的方式,&&配合std::forward實現完美轉發,等等,有興趣的可以看下《Effective Modern C++》。

以上也適用於宏。

關於編譯器

跨端開發勢必要了解多種平台下的編譯器,這裡面主要代表是clang、ms(也成vs)、gcc等等,編譯器的主要區別,這裡不做主要的介紹了,可以去google下clang的前世今生,以及幾種編譯器的區別,和對應的使用平台。

clang作為一款飛速發展的編譯器,除了編譯速度有飛速的提升外,錯誤提示也非常明確,這裡強烈建議跨端開發者,如果有可能優先進行clang作為主要的默認編譯器進行開發,良好的錯誤提示將提高極大的效率,同時clang的代碼檢查將更為嚴格和規範,這也利於代碼進行跨平台編譯。

這裡再再插一句,之前在知乎上看過一篇文章對比各種編譯器,在比較clang與gcc時,排在第一次位的不是我們通常說的編譯速度和錯誤提示以及更小的編譯產物(這些都是普遍知道的),是 license,gcc的GPL的限制讓BSD許可下的以LLVM為代表的飛速發展,如果不是這個限制相信今天以LLVM為代表的的一系列編譯器都是屬於gcc。

所以「做技術的同學不要以為技術牛就可以打天下,精準的市場地位有時候可以解決很多問題」,這句話說的還挺好的,與君共勉。

關於轉換層

如果做跨模塊開發,請堅守一個原則,轉換層不要做任何業務代碼邏輯以及特殊定向代碼邏輯。

轉換層也成語言膠水層,是c++到oc, c++到java,以及其他,彼此相互語言轉換的代碼層。

通常wrapper堅守原則後,維護性會得到大幅度提升,專注於c++代碼的即可,對於語言轉換層,業界也有不少自動化轉譯的工具,諸如Djinni。

結束

在通往跨端開發的路上,我漸漸的從一個小白到逐漸羽翼豐滿,除了要感謝團隊給的機會外,非常感謝這一路上很多同學、特別是跨部們的同學幫助,感謝,比心~

另外團隊目前也在搞基於跨桌面端的研發框架支撐相關工作,也會很快出爐,敬請期待。

最後回歸主題,跨端cpp開發閉坑指南遠不止這些,歡迎一起補充添加。鳴謝。

團隊介紹

我們是淘系技術部終端體驗平台跨終端團隊,業務上負責為千萬級商家打造最高效的一站式工作檯千牛,為淘寶上億商家和消費者提供穩定高效的端到端消息IM服務;技術上深耕C++跨終端及PC桌面端技術(Windows&Mac),為商家,消費者提供穩定,可靠,高效的客戶端產品。歡迎志同道合的小夥伴,毛遂自薦,團隊歡迎你,簡歷投遞郵箱:wdw159603@alibaba-inc.com

🍊橙子說

大淘寶技術新春拜年

「虎虎虎」

紙質紅包大派送

關注」淘系技術「回復"紅包「即可獲得領取方式

(2月28日18:00截止)

✿拓展閱讀

作者|鹿慕
編輯|橙子君
出品|阿里巴巴新零售淘系技術
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()