特效側使用者體驗優化實戰 —— 包體積篇

語言: CN / TW / HK

1 特效包體積之於抖音

1.1 一句話解釋包體積是什麼?

包體積主要指的是應用安裝包大小的體積,比如 App Store 裡的安裝包顯示的安裝大小。

1.2 為什麼要優化包體積?

隨著應用的能力更新迭代,應用安裝包體積將逐步增大,使用者下載應用消耗流量產生資費進一步增長,使用者下載意願會相對下降;另一方面,隨著包體積增大,安裝應用的時間會相對變長,影響使用者使用感受;對於ROM較小的低端手機,應用解壓後記憶體佔用更大,部分手機管家會提示記憶體不足提示解除安裝,直接影響使用者使用。

1.3 特效側在抖音裡的包體積貢獻

抖音目前由多條業務線組成,每條業務線都類似中臺的角色,特效中臺是抖音其中一環;目前,特效由 effect 和 lab 聚合為EffectSDK,作為一條獨立業務線結算包體積在抖音中的佔比。

1.4 特效側的包體積組成

EffectSDK 的包體積由兩方面組成:二進位制檔案(即可執行檔案)、其他資原始檔(圖片、配置檔案等)。二進位制檔案主要是由程式碼生成的可執行檔案,資原始檔指代的如內建的模型檔案、素材檔案、配置檔案等。

作為中颱,特效 EffectSDK 中二進位制程式碼佔用了絕大多數體積。與抖音、頭條等應用做包體積優化思路不同,特效在資源壓縮等部分能做得比較少;由於特效是作為中颱對抖音進行業務支援,通過庫的形式提供特效能力,在無用資源刪除、無用程式碼去除、程式碼優化上有較大空間。因此,特效側效能優化主要側重於在支援多功能的基礎上儘量減小包體積,提升程式碼質量,實現程式碼效率與程式碼體積的平衡。

圖片

2 包體積優化的背景知識

特效側在抖音裡的能力由 C++ 程式碼編寫支撐,編譯後生成靜態庫,最後連結至可執行檔案中。從程式碼至二進位制檔案的過程中,由編譯器為我們做好預處理、編譯、彙編、連結等過程,最後 Android 端生成 ELF 格式檔案,iOS 端生成 Mach-O 檔案。ELF 格式的檔案有四種,包括可重定位檔案(Relocatable File)、可執行檔案(Executable File)、共享目標檔案(Shared Object File)、核心轉儲檔案(Core Dump File),其中,共享目標檔案,即 xxx.so 檔案,包含可在兩種上下文中連結的程式碼和資料,連結編輯器可以將它和其它可重定位檔案和共享目標檔案一起處理,生成另外一個目標檔案;另外,動態連結器(Dynamic Linker)可能將它與某個可執行檔案以及其它共享目標一起組合,建立程序映像。特效側即以共享目標檔案(libeffect.so)的形式做好抖音特效拍攝能力支撐。

圖片

圖片

圖片

由於ELF檔案參與程式的連結與執行,通常有兩種檢視方式:一種是連結檢視,一種是執行檢視(下述左圖);編譯器和連結器會按照連結檢視,以節區(section)為單位,按節區頭部表(section header table)形成節區的集合;載入器將按照執行檢視,將檔案以段(segment)為單位,按照程式頭部表(program header table)將其視為段的集合。通常,可重定位檔案(xxx.o)將包含節區頭部表,可執行檔案(xxx.exe)將包含程式頭部表,共享目標檔案(xxx.so)兩者都包含。

圖片

圖片

下面是使用 binutils 工具檢視 effect_sdk.so 中的 section 部分資訊:

``` $ greadelf -h libeffect_sdk.so ELF Header:   Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00    Class:                             ELF64   Data:                              2's complement, little endian   Version:                           1 (current)   OS/ABI:                            UNIX - System V   ABI Version:                       0   Type:                              DYN (Shared object file)   Machine:                           AArch64   Version:                           0x1   Entry point address:               0x0   Start of program headers:          64 (bytes into file)   Start of section headers:          22954168 (bytes into file)   Flags:                             0x0   Size of this header:               64 (bytes)   Size of program headers:           56 (bytes)   Number of program headers:         8   Size of section headers:           64 (bytes)   Number of section headers:         29   Section header string table index: 28 $ greadelf -S libeffect_sdk.so There are 29 section headers, starting at offset 0x15e40b8:

Section Headers:   [Nr] Name              Type             Address           Offset        Size              EntSize          Flags  Link  Info  Align   [ 0]                   NULL             0000000000000000  00000000        0000000000000000  0000000000000000           0     0     0   [ 1] .note.androi[...] NOTE             0000000000000200  00000200        0000000000000098  0000000000000000   A       0     0     4   [ 2] .note.gnu.bu[...] NOTE             0000000000000298  00000298        0000000000000024  0000000000000000   A       0     0     4   [ 3] .dynsym           DYNSYM           00000000000002c0  000002c0        00000000000107e8  0000000000000018   A       4     1     8   [ 4] .dynstr           STRTAB           0000000000010aa8  00010aa8        000000000001b0f9  0000000000000000   A       0     0     1   [ 5] .gnu.hash         GNU_HASH         000000000002bba8  0002bba8        000000000000347c  0000000000000000   A       3     0     8   [ 6] .hash             HASH             000000000002f028  0002f028        0000000000004c18  0000000000000004   A       3     0     8  ... ... Key to Flags:   W (write), A (alloc), X (execute), M (merge), S (strings), I (info),   L (link order), O (extra OS processing required), G (group), T (TLS),   C (compressed), x (unknown), o (OS specific), E (exclude),   p (processor specific) ```

通常每個節區(section)負責不同的功能,儲存在不同的位置,節區的大小是程式碼編譯後大小的反饋。說到底,特效側最終的包體積由 section 和 headers 的大小共同決定。優化包體積,即是優化程式碼的編寫效率、編譯方式,減少各個節區的大小。

int gInitVar = 24;  //-- .data section int gUninitedVar;  //-- .bss section  void func(int i) {     printf("%d\n", i); //-- .text section } int main(void) {     static int sVar = 23; //-- .data section     static int sVar1; //-- .bss section      int a = 1;     int b;     func(sVar + sVar1 + a + b); //-- .text section     return 0; }

圖片

3 包體積優化技巧

在瞭解了基礎的包體積組成後,我們可以針對性的對編譯選項、程式碼進行調整,以優化包體積。

iOS/Android 均可以通過優化編譯選項來優化程式碼體積。整理了常用的一些。

3.1 編譯優化

3.1.1 使用 Oz 替代 Os

  • 編譯選項
    • -Oz替代-Os
    • 示例:
    • set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -Oz")

3.1.2 減小 unused code 的體積

  • 編譯選項
    • -ffunction-sections
    • 把每個function放到自己的 COMDAT 段(COMDAT 段被多個目標檔案所定義的輔助段。該段的作用是將在多個已編譯模組中重複的程式碼和資料的邏輯塊組合在一起。COMDAT 在 C++ 的虛擬函式表和模板的編譯連結中,起著非常重要的作用。)
    • 支援 Linux/OS X,不支援windows
    • -fdata-sections
    • 為原始檔中每個變數啟用一個 elf section 的生成
  • 示例:

    • set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -ffunction-sections -fdata-sections -fvisibility=hidden -g") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -ffunction-sections -fdata-sections -fvisibility=hidden -g")
  • 連結選項
    • -Wl, --gc-sections( Android 端)
    • 當編譯器選擇用-ffunction-sections, -fdata-sections編譯檔案時,靜態的庫體積將增大,此時呼叫-Wl, --gc-sections,能消除dead段沒有用到的code和data的體積。
    • -dead_strip( iOS 端)
  • 示例:

    • set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--gc-sections")

3.1.3 開啟連結優化

  • 編譯選項
    • -flto Oz
  • 連結選項
    • -O3 -flto
    • lto為 link-time optimization ,在編譯和連結時需要同時開啟。編譯時,會將各檔案寫入專有的 section ,再連結時將它倆視為同一單元進行轉換和優化。但有個缺點,會在一定程度上拖慢編譯速度
    • 注意:lto編譯時可以和-Oz共存,但連結時只能跟O1/O2/O3共存,無法和Oz/Os共存,如果同時開啟了,將會報下面的錯誤:
    • $ clang -Os -fuse-ld=lld -flto test.c ld.lld: error: -plugin-opt=Os: number expected, but got 's' clang-9: error: linker command failed with exit code 1 (use -v to see invocation) $ clang -Oz -fuse-ld=lld -flto test.c ld.lld: error: -plugin-opt=Oz: number expected, but got 'z' clang-9: error: linker command failed with exit code 1 (use -v to see invocation)
  • 示例:

    • if (NOT DEFINED ENV{DISABLE_LTO})     set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -flto -fPIC") endif()
    • set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl, --gc-sections -fuse-ld=gold -Wl, --icf=safe -O2 -flto") if (NOT DEFINED ENV{DISABLE_LTO})     message(STATUS "DISABLE_LTO=$ENV{DISABLE_LTO} +++ LTO enabled")     set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -fuse-ld=gold -Wl, --icf=safe -O2 -flto") else()     message(STATUS "DISABLE_LTO=$ENV{DISABLE_LTO} +++ LTO disabled") endif()

3.1.4 關閉 exception 和 rtti

  • 編譯選項
    • -fno-exceptions
    • 當開啟-fno-rtti開關時,將禁用 rtti 機制,減小包體積。
    • -fno-rtti
    • 當開啟-fno-exceptions 開關時,將禁用 exception 機制,減小包體積。
    • 上述兩種屬於比較激進的做法,同時也需要程式碼配合,但在能保障程式碼正確性和穩定性的情況下,也能較大幅度的優化包體積。目前特效側已經儘量避免不必要的 rtti 和 exception 機制。
  • 注意:缺少異常處理和 rtti ,需要 coder 能寫出更高品質的程式碼。
    • -fno-excpetion需要配合一定的程式碼修改:
    • if(!running) {     // throw std::runtime_error("runtime error") // 不可用     errCode = getRuntimeError();     return errCode; }
    • -fno-rtti也需要配合一定程式碼修改:
    • DerivedTarget &target = getTargetPtr(); // dynamic_cast<BasicTarget *>(target.get())->fun(); // 不可再用 static_cast<BasicTarget *>(target.get())->fun();

3.1.5 自動刪除引入的靜態庫中的符號

  • 連結選項
    • -Wl,--exclude-libs,ALL(Android端)
    • 刪除庫"ALL"裡自動匯出的符號(這裡ALL替換成不需要的庫名,比如--exclude-libs lib,lib,...)
  • 注意:iOS 不支援這個連結選項,因為 macOS 將--exclude-libs作為預設選項

(如果 iOS 要往庫裡引入符號,需要手動開啟-reexport-l$(UR_LIB)選項)

if ("${CMAKE_BUILD_TYPE}" STREQUAL "Release" AND ANDROID)     foreach(LIB ${LINK_LIB_LIST})         set(CMAKE_SHARED_LINKER_FLAGS "{CMAKE_SHARED_LINKER_FLAGS} -Wl,--exclude-libs,lib{LIB}.a")     endforeach() endif()

目前特效在 Android 端均採用了這個選項。

3.1.6 減少符號表

  • -fvisibility=hidden
    • 可隱藏符號的可見性,防止符號衝突,同時減小包體積。
  • 注意:出錯時上層可能無法第一時間定位問題

set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -ffunction-sections -fdata-sections -fvisibility=hidden -g") set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -ffunction-sections -fdata-sections -fvisibility=hidden -g")

目前特效側均使用-fvisibility=hidden

3.1.7 動態連結c++

動態連結 libstdc++ 庫,避免增大庫檔案。

3.2 程式碼優化

一句話總結:程式碼量越少,包體積越小,從經驗來看100行程式碼大概佔用1~5K體積;超出這個行/體積 比,程式碼肯定有問題。

3.2.1 不要有無效的判斷邏輯( if...else... )

可以採用表驅動的方法實現 if else ,減少不必要的程式碼引用。

3.2.2 減少模板展開、巨集展開

模板展開非常佔據體積,尤其是對於同一種形式的程式碼,template 會擴充為多個不同的類。此時最好把公共的部分提取出來,宣告為一個 static method。

如下面的繫結變數的方法:

```     template      static void bindArgs(const Demo& d, T func)     {         auto m = createFun(func);         m->mName = d.name         for (auto i = 0; i < m->getArgc(); ++i)         {             if (i < d.args.size())                 m->mArgTypes[i].name = d.args[i];         }     }

template      static void bindArgs(const Demo& d, T func, const Var& arg1)     {         auto m = createFun(func);         if (!m)             return;         m->mValues.push_back(arg1);         for (auto i = 0; i < m->getArgc(); ++i)         {             if (i < d.args.size())                 m->mArgTypes[i].name = d.args[i];         }     }          // static void bindArgs(const Demo& d, T func, const Var& arg1, const Var& arg2)     // {  ```

可修改為:

``` // bindArgs 提取出來 static void bindArgs(const Demo& d, Fun* m) {     for (auto i = 0; i < m->getArgc(); ++i)     {         if (i < d.args.size())             m->mArgTypes[i].name = d.args[i];     } }

template  static void bindArgs(const Demo& d, T func) {     auto m = createFun(func);     m->mName = d.name;     bindArgs(d, m); }

template  static void bindArgs(const Demo& d, T func, const Var& arg1) {     auto m = createFun(func);     if (!m)         return;     m->mValues.push_back(arg1);     bindArgs(d, m); } ```

3.2.3 避免不必要的 stl/std 使用

比如,部分回撥可以使用函式指標:std::function <>作為一個 class ,它的體積成本必然比 void * fun 這樣一個函式指標要來的高;

// using FunInstantiate = std::function<FunInterface*()>; // 不再使用 using FunInstantiate = FunInterface*(*)();

比如,常量字串引用時可以採用 const char* 型別,避免編譯器呼叫隱式拷貝構造;

// void DemoClass::fun(const std::string &name, const DemoPtr &demoPtr) // 不再使用 void DemoClass::fun(const char* name, const DmoePtr &demoPtr) {     //... }

3.2.4 標頭檔案不要出現 const、static 變數的定義

標頭檔案中 const / static 型的變數,會被引入至對應的 cpp 檔案,相當於每一份.o 都引入了一長串常量字串。

3.2.5 不要出現大的陣列

大的陣列會佔用陣列大小的體積。

3.2.6 減少不必要的虛基類/虛擬函式

// class Child : virtual public Parent // 不再使用     class Child : public Parent     {         //...     }

4 包體積監測工具

4.1 為什麼要做包體積監測工具

抖音每個版本都會有非常多的新能力更新換代,每次更新每個需求均會導致包體積的變更。為了能更好的監測包體積的變化、確認包體積增長的原因,提升 ROI ,引入包體積監測工具,更直觀的確認包體積增長原因,攔截異常增長,輸出每個每個需求帶來的包體積增長大小、包體積增長原因,及時給出包體積告警、定位異常增量 case ,減緩包體積增長,推動業務優化。

圖片

4.2 如何進行包體積監測

特效側目前使用的包體積監測工具來源於 google 的開源二進位制檔案體積分析工具 bloaty ,用於分析二進位制檔案(xxx.exe, xxx.bin)、共享目標檔案(xxx.so)、物件檔案(xxx.o)和靜態庫(xxx.a),支援ELF\Mach-O\WebAssembly 格式。它能梳理出檔案中各部分的體積組成,拆分出各個 section 大小,結合symbol資訊,反推出各方法、原始檔的包體積大小。

以特效側 libeffect_sdk.so 為例,對 .so 檔案進行元件單元、原始檔分析,擷取部分輸出結果:

FILE SIZE     --------------    10.3%  2.25Mi    [section .rela.dyn]    7.2%  1.58Mi    [section .rodata]    7.2%  1.57Mi    Bindings.cpp    3.9%   877Ki    [section .data.rel.ro]    2.0%   445Ki    [section .text]    1.9%   418Ki    [section .gcc_except_table]    1.0%   213Ki    base/EffectManager.cpp    0.7%   149Ki    bef_info_sticker_api.cpp    0.6%   140Ki    base/RenderManager.cpp    0.6%   138Ki    Runtime/Engine/Foundation/Bindings.cpp    ...

利用上述工具,即可較為清晰的定位各檔案帶來的包體積增長。

4.2.1 包體積監控工具工作流程

包體積監測工具是當前特效需求上車前必過的一環。所有需求在 MR(merge request)提出、CI 打包完成後都會經過包體積的檢查,僅包體積增量符合預期的需求允許跟版合入,所有包體積增量與需求一一對應,記錄在案。

圖片

4.2.2 包體積監測工具的分析能力

包體積分析工具支援單個檔案分析和版本迭代對比分析。

對於單檔案分析,由於特效側主要通過 .so 檔案進行交付,在每個 MR 打包完成後,工具將自動獲取對應的 .so 檔案和 .so.symbol 檔案後,對庫檔案的包體積組成、包體積來源進行分析,輸出所有方法函式、節區(section)、編譯單元(xxx.cpp)帶來的包體積大小,確認大小後通過關鍵字匹配確認包體積的增量來源模組,給出最後的各模組單元、編譯單元的包體積 profile 。

另一方面,由於特效側能力總是通過需求更新迭代的,每次有實質性的需求提交時,將會對比上一版本與當前版本的包體積差異,做好每個版本需求帶來的增量來源記錄。當版本比對結果帶來的增量超過預期值時,將調起通訊 api ,將包體積超標資訊發出進行報警。

圖片

圖片

4.2.3 包體積資料記錄本

所有需求的包體積增量將記錄在包體積記錄本中:當服務收到需求事件時,將呼叫 bits/meego 介面,請求需求資訊和包大小預設 exp_pack_size 增量寫入 mr_pkg_size 表;等到本地出包完成後,實際的包大小增量 real_pack_size 將被記錄入 mr_pkg_size 表,並將預期值與實際增量進行對比。

最終,所有的包體積增量與歷史的需求增量來源被記錄在案,並通過表查詢介面,在網頁端可根據需求名 / 時間段 / 分支名 / commit id 等條件按圖索驥,確認包體積增長來源。

圖片

5 總結

經過上述程式碼體積優化積累、實時體積監控、需求增量落實到人三位一體,控制特效側包體積有序增長,提升程式碼效能。

6 關於我們

特效團隊,旨在通過特效平臺連線虛擬與現實,通過探索更多的特效和互動方式,激發內容創造,豐富使用者生活;目前,特效已深度支撐著抖音、剪映、西瓜、頭條、輕顏、Faceu、飛書等產品;覆蓋中/長/短影片創作、直播、社群、廣告等行業領域。我們是特效使用者體驗優化團隊,屬於特效團隊下的子團隊,負責支援解決特效業務場景中遇到的效能問題,通過持續優化演算法、渲染方案,壓榨裝置效能釋放,提供最極致的使用者使用體驗。歡迎掃描下方二維碼進行簡歷投遞,加入我們!

  特效效能優化工程師-抖音:北京/上海/杭州/深圳職位開放 

圖片

圖形影象研發工程師-效能優化方向:北京/上海/杭州/深圳職位開放

圖片