作者:劉天宇(謙風)
系列文章回顧《向工程腐化開炮 | proguard治理》《向工程腐化開炮 | manifest治理》《向工程腐化開炮:Java代碼治理》。本文為系列文章第四篇,聚焦於Android 資源,這一細分領域。對工程腐化,直接開炮!
準確的説,本文主角是Android資源,而java資源歸屬到java代碼治理範疇,並在《向工程腐化開炮:Java代碼治理》一文中給出了應對方案。
Android資源從定義和使用方式來看,可以分為Resource和Asset兩個大類。前者提供受控的結構化訪問方式,每個資源均有唯一id標識,以及多種配置限定符來支持多語言、多設備、多特性等能力;後者提供原始且相對自由的目錄和文件訪問。Resource類型是絕大部分資源使用場景下的最佳選擇,本文主要聚焦的即是這種類型資源,對衝突、無用、缺失類引用、硬編碼文本,這幾種腐化情況,開展工具研發,以及治理實踐。
基礎知識
本章先簡要介紹一些基礎知識,方便大家對Android資源有一個“框架性”的清晰認知,為理解第二章治理實踐內容打下基礎。此外,也嘗試以獨特視角,來講解一些有趣的技術點。
1.1 資源分類
對於Resource資源,按照使用場景,官方文檔已經給出了劃分和具體説明。本節以資源編譯後,對應R內部類的類型,給出一個分類:
以上24種資源,均可以通過R.<type>.<name>形式在java代碼中引用,其中一些還可以通過@<type>/<name>形式在manifest和資源中引用。對上述分類中「是否獨立文件」、「是否位於resources.arsc」兩個維度進行一些解讀:
- 是否獨立文件。一個資源如果對應一個完整的獨立文件,這種屬於File-Base Resource,在最終apk的res目錄下也會存在一份對應文件;否則,屬於Value-Base Resource,在apk中沒有獨立文件與之對應,其值(如果有)存儲在resources.arsc中。其中color類型比較特殊,單一color資源是Value-Base,但是顏色狀態列表(ColorStateList)屬於File-Base。此外,是否獨立文件,是從資源編譯後這一視角來看的,在定義資源時,Android提供了一種內嵌xml資源的形式,可以把多個獨立文件類型資源,寫在一個xml文件中,在此不展開討論;
- 是否位於resources.arsc。絕大部分資源,在R$<type>類中field的取值,都是0x7fxxxxxx,並且在resources.arsc中都有一條對應記錄。對於File-Base資源,記錄值是file的相對路徑,對於Value-Base資源,記錄值就是資源值本身。需要注意的是,styleable類型資源比較特殊,僅存在於R$styleable類中,其field取值也並不是0x7fxxxxxxx格式,而是整型或整型數組,並且在resources.arsc中並不存在。
通過一個styleable定義示例,來幫助我們理解上述知識:
# 資源定義於 res/value/attrs.xml
<resources>
<declare-styleable name="DeclareStyleable1" >
<attr name="attr_enum" format="enum">
<enum name="attrEnum1" value="1"/>
<enum name="attrEnum2" value="2"/>
</attr>
<attr name="attr_integer" format="integer"/>
<attr name="android:padding" format="dimension"/>
</declare-styleable>
</resources>
在apk編譯過程中,生成以下R.java代碼:
# R.java文件中,生成以下代碼
public static final class id {
public static final int attrEnum1=0x7f060000;
public static final int attrEnum2=0x7f060001;
}
public static final class attr {
public static final int attr_enum=0x7f020000;
public static final int attr_integer=0x7f020001;
}
public static final class styleable {
public static final int[] DeclareStyleable1 = {0x010100d5, 0x7f020000, 0x7f020001};
public static final int DeclareStyleable1_android_padding=0;
public static final int DeclareStyleable1_attr_enum=1;
public static final int DeclareStyleable1_attr_integer=2;
}
最後,在resources.arsc中,生成以下記錄:
# resources.arsc中,生成記錄
type | id | name | value
id 0x7f060000 attrEnum1 None
id 0x7f060001 attrEnum2 None
attr 0x7f020000 attr_enum 1,2
attr 0x7f020001 attr_integer 0
一個styleable定義,最終會生成一連串產物,由此可見,Android資源的處理邏輯,相對還是比較複雜的。在這個例子中,還有幾個有意思的技術點,值得拿來講一講:
- 一個attr,name使用android:xxxx,在R.java和resources.arsc中不會生成對應內容,因此在語意可複用時,使用系統提供的attr,可以節省一點包大小空間;
- 如果多個styleable或者style,定義了同名attr,實際只會生成一個attr資源,相當於提高了複用度;
- attrEnum1、attrEnum2這種id類型資源,如果其它類型資源(例如layout)中也有同名定義,那麼實際也只會生成一個id資源,同樣也提高了複用度。
好了,對於資源分類,就到此為止,如果對於資源編譯、R.java、resources.arsc等還不夠了解,也沒有關係,後面小節或許會給出答案。
1.2 資源引用
資源在定義後,就需要從另外的地方對其進行引用。從引用確定性這個維度來看,可以分為直接和間接(動態)兩種;從引用元素為度來看,可以分為java代碼、manifest、資源三種:
圖注:資源引用方式
其中,間接(動態)引用,提供動態化的資源引用方式,可以在運行時,根據上下文條件,決定引用哪個資源,靈活度很高。但是,這種資源引用方式,相對於直接引用,需要額外進行由資源名稱查找資源id的處理,因此性能略差,謹慎使用。
1.3 資源編譯
接下來,看看資源的編譯過程:
圖注:資源編譯過程
首先,資源會進行合併,同名資源僅保留一份,同時,manifest也會進行合併。接下來,會以上述二者作為核心輸入數據,通過AAPT(2)進行資源編譯,具體的編譯過程比較複雜,網絡上也有比較全面的講解(可以參考這篇文章:https://www.kancloud.cn/alex_...), 這裏重點關注資源編譯產物,以及與其它處理邏輯的關係:
- AndroidManifest.xml文件。其中對資源的引用,會替換為對應資源id,並編譯為二進制格式,最終會被打包到apk中。
- resources.arsc文件。資源符號(索引)表,記錄所有資源id與各配置下的資源值,最終也會被打包到apk中。
- 處理(編譯)後的資源文件集合。所有需要編譯的獨立資源文件(例如layout),均會編譯為二進制格式,和不需要編譯的資源文件一起,最終被打包到apk中。
- R.java文件。記錄資源類型/名稱,與id值的對應關係,用於在java代碼中直接引用。每個模塊(subproject、flat aar、external aar)都會生成對應的package.R.java文件,最終和其它所有java源文件一起,共同進行javac編譯。
- 資源對應keep規則文件。主要包括layout中view節點對應java類,onClick屬性值對應java方法,以及manifest中四大組件對應java類。這些keep規則,會與其它自定義keep規則一起,用於後續的proguard處理。
從上述整個過程來看,資源編譯與其它幾個核心處理過程,都有緊密聯繫,因此,瞭解資源編譯過程,對掌握整個apk構建,具有重要價值。
1.4 資源裁剪
google官方的Android Gradle Plugin,提供了資源裁剪功能。核心原理是,計算資源的直接引用關係,以manifest和java代碼中的引用,作為根引用,所有不被引用到的資源,均屬於無用資源。看起來是一個非常有效的功能,但是由於java代碼中存在間接(動態)引用,為了將這部分引用也覆蓋到,採用了比較保守的策略:收集java代碼中的所有字符串常量,如果資源名稱以這些常量開頭,則也認為資源有引用。除此之外,還有幾個邏輯,用於處理特殊的引用方式。上述處理邏輯,有以下幾個問題:
- 如果通過Resources.getIdentifier動態引用資源時,名稱參數完全是一個變量,那麼會導致相關資源被誤刪;
- 如果java代碼常量池中,幾乎包含所有單個字符,例如a-z,1-9,那麼所有資源均會被認為有引用,導致不會裁剪任何一個資源(優酷就是如此)。
因此,資源裁剪功能,從技術原理上看,無論如何都是一個非確定性算法,必定會存在誤裁、漏裁的可能性。對此,google提供了白名單機制,來解決誤裁問題,還有嚴格模式,用於取消對間接(動態)引用的保留邏輯。
對於歷史包袱不重的app,儘早開啓這項功能,有利於減輕包大小負擔。對於代碼複雜度高,歷史包袱重的大型app(優酷就是如此),應該會存在不少間接(動態)引用,不開啓嚴格模式,幾乎無效果,開啓嚴格模式,存量確認&加白名單的成本又極高。對此,優酷的選擇是,通過建立獨立的無用資源檢測功能,結合包大小治理,促進從源頭直接刪除資源,這樣既可以降低資源處理耗時,又可以實現降低包大小效果。對於新增無用資源,則通過包大小卡口,實現非實時(可延遲)清理。
1.5 幾個有趣的問題
最後,來講幾個比較有趣,並且不容易被注意到的技術點。
被忽視的一員 - id類型資源
id類型資源,作為唯一標識符,在Android資源體系下,承擔“穿針引線”作用。例如最常用的,在layout中定義一個view節點,賦予其一個id名稱,這樣在java代碼中,就可以方便的獲取這個view實例,從而進行後續各種操作。再舉個例子,在前面styleable示例中,一個enum類型attr包含的每一個枚舉值,都會生成一個對應id類型資源。
id類型資源,在編譯期的一個重要特性是可以全局複用,這一點在前面styleable示例中,已經講述過。在app運行時,id類型資源的特性是,局部唯一即可。例如在一個layout中,或者在一個enum類型的attr中,都是如此。講到這裏,有些同學一定能夠想到,我們是不是可以利用這兩個特性,在保證運行時局部唯一性前提下,僅保留一個最小集合,其它所有定義和引用,均在這個最小集合內選取即可,而這個最小集合的數量,取決於所有局部使用場景中,需要id數量的最大值。舉個例子:
# styleable類型資源,定義於 res/value/attrs.xml
<resources>
<declare-styleable name="DeclareStyleable1" >
<attr name="attr_enum" format="enum">
<enum name="attrEnum1" value="1"/>
<enum name="attrEnum2" value="2"/>
</attr>
<attr name="attr_integer" format="integer"/>
<attr name="android:padding" format="dimension"/>
</declare-styleable>
</resources>
# layout類型資源,定義於 res/layout/main.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/main_textview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/purple_200"
android:text="Hello World!"/>
</LinearLayout>
上述一共生成3個id類型資源:attrEnum1、attrEnum2、main_textview,這兩個使用場景中,styleable需要2個id,layout需要一個id,所以最小id集合只需要包含2個id。假設我們可以在資源編譯過程中,可以將"@+id/main_textview"修改為"@+id/attrEnum1",就可以減少1個id類型資源。在優酷這樣複雜度的app中,共有1.3萬多個id類型資源,而所有局部使用場景中,需要id數量的最大值,相信一定不會超過兩位數。一個id類型資源在apk中佔用的大小(Byte),可以簡單認為等於id名稱長度,保守估計以平均20Byte來計算,1.3萬個id資源,可節省包大小250KB。由於收益並不顯著,並沒有實際進行開發,作為一個有趣的思考,留給本文讀者。
資源與java代碼的橋樑 - R類
通過前面講解,相信讀者對R類已經具備一定了解。在這裏,我們考慮幾個情況。
第一個情況,每個模塊(subproject、flat aar、external aar)都會生成對應package.R.java文件,但是這些文件包含內容,都是<app_package>.R類的子集。那麼,我們是不是可以,移除所有模塊的R類,統一使用app的R類,以此來降低包大小呢?答案是肯定的,事實上優酷在apk構建過程中,會刪除所有模塊R類,並將java代碼中對這些R類的引用,轉換為對appR類的引用。通過這種方式,降低了MB量級的apk大小,模塊數量越多,收益越明顯。
第二個情況,R類內容非常簡單,就是記錄了資源類型/名稱,與資源id值的對應關係,manifest和資源這二者對資源的引用,在編譯過程中,已經轉換為對應資源id值,那麼,如果我們把所有java代碼中R.<type>.<name>的引用,也全部替換為對應id值,是不是R類就可以刪除了呢?答案是肯定的,在已經完成第一種情況的優化後,這個處理的收益比較有限,因此並沒有實際投入研發和使用。但是我們確實可以這麼做!
資源百曉生 - resources.arsc
resources.arsc文件,作為資源符號(索引)表,記錄所有資源類型、名稱、id值,以及各配置下的值。所有Resource類型資源(運行時視角,排除編譯期視角的styleable資源)均記錄在案,app運行時,無論java代碼還是資源,都是拿着資源id值,到resources.arsc中來獲取資源值,稱之為“資源百曉生”一點都不誇張。這個查找過程非常高效,相當於給定一個key,獲取其在一個hashmap中的值。
實際上,通過Resources.getIdentifier這種間接(動態)方式獲取資源id值時,也是以資源類型+名稱,在resources.arsc中進行反向查找,找到後,再繼續通過id值獲取資源值。這個查找過程,相當於給定一個值,獲取其在一個hashmap中的key。那麼有沒有什麼方式,可以更高效實現這種運行時靈活的引用資源呢?一個比較自然的想法,是通過java反射獲取R.<type>.<name>值,那麼問題來了,相對於Resources.getIdentifier方式獲取,哪種性能更好一些?答案可能並不是簡單的二選一,耗時可能與資源數量,以及是否第一次查詢同一種類型資源,都有關係,答案就留給讀者來思考和驗證吧。
治理實踐
隨着工程模塊&功能增加,資源腐化逐步積累:同名資源的衝突情況愈發頻繁,導致多次構建apk,資源值無法保障一致性;資源引用關係複雜,代碼刪除後往往會忘記,或者不敢輕易刪除對應資源,導致無用資源持續積累;layout中引用自定義view,但是view的java實現類被刪除,app運行時layout被“加載”時會引發java異常;資源中的硬編碼文本,帶來線上隱私合規風險,或者國家/地區/宗教文化爭議問題。上述諸多問題,都是過往優酷與資源“腐化”鬥爭中,不斷遇到的真實問題,通過相關工具建立有效的檢測能力,並基於此形成日常研發卡口機制,在確保問題零新增前提下,逐步消化已有存量問題。
在問題定位、排查過程中,快速獲取資源來自哪個模塊,是一個基本訴求。二、三方模塊大量引入,以及app工程模塊化程度提高,都使上述信息獲取的成本變得越來越高。為此,首先開發了模塊包含資源列表功能,可以快速查看,目標資源位於哪個模塊(app工程、subproject工程、flat aar、外部依賴模塊):
com.youku.android:aln:1.9.49
|-- string/m_mode
|-- layout/pager_last
|-- dimen/h_n_bar_pop_star
|-- asset/config/custom_config.json
com.youku.android:YHP:1.23.511.1
|-- layout/channel_list_footer
|-- layout/f_cover_s_feed_item
|-- drawable-night-xhdpi-v8/ic_ho
接下來,對各個資源“腐化”項的治理實踐,逐一講解。
2.1 衝突資源
衝突資源,是指來自不同模塊的同名資源,其對應配置下的內容值不一致。在資源編譯過程中,同名資源只會保留一份,選擇哪個資源可以認為是“隨機的”(實際和模塊聲明順序有關),這會導致每次構建出來的apk,對應資源值可能會發生變化。衝突資源,會給運行時帶來不確定性風險,輕則文本內容、尺寸大小、UI顏色發生非預期變化,重則導致異常產生。
在優酷歷次迭代中,曾經發生多次衝突資源導致的線上崩潰,為了解決這個頑疾,首先研發了衝突資源檢測工具,示例結果如下:
[conflict] drawable/al_down_arrow
|-- xhdpi-v4
| |-- md5:cc2ef446bf586b03fd08332a5a75b304 (com.ali.user.sdk:au:4.10.6.18)
| |-- md5:5f9c59ec3fba027c5783120effa12789 (com.ta.android:lo4android:4.10.6.18)
[conflict] string/str_retry
|-- en
| |-- not calculated (com.ali.android.phone:bee-build:10.2.3.358)
|-- default
| |-- 重試 (com.ali.android.phone:photo-build:10.2.3.57)
| |-- 點擊重試 (com.ali.android.phone:bee-build:10.2.3.358)
在上述檢測結果中,當同名資源在同一配置下,超過兩個模塊包含此資源值時,才可能發生衝突,因此也才會進行資源特徵值計算,否則會顯示為not calculated。不同類型資源的特徵值計算方式如下:
與此同時,提供資源名稱、模塊兩種不同顆粒度的忽略名單配置,以臨時排除一些二、三方模塊之間的衝突資源。更近一步,提供選項,當檢測結果不通過時,終止構建過程,形成卡口機制。
優酷在2020年,首先研發了第一版衝突資源檢測工具,當時存量衝突資源共計600多個,之後聯合QA同學進行兩輪清理專項,降低到100個以內,2021年初卡口上線後,截至當前已降至40多個(主要來自二、三方模塊之間的衝突):
圖注:衝突資源治理情況
衝突資源卡口上線至今,累計攔截13次,有效防止衝突資源,引發的線上非預期情況,甚至是app崩潰的嚴重故障。
2.2 無用資源
前面「資源引用」一節,已經對資源的引用關係,進行了基礎知識講解。總結下,資源可能在如下三個地方進行直接引用:
- java代碼。通過R.resourceType.resourceName方式引用,例如R.string.app_name;或者通過資源id方式,直接引用,例如0x7fxxxxxx;
- 清單文件AndroidManifest.xml;
- 其它資源。
以java代碼和manifest作為引用根節點,對資源引用關係進行完全展開,最終未被包含到的資源,即為無用資源。對於通過Resources.getIdentifier這種間接(動態)方式引用的資源,不包含在此處的資源引用關係計算過程中,因此,無用資源檢測結果,需要確認是否存在這種引用方式。基於google官方AndroidGradlePlugin中的無用資源分析邏輯,全方位增強對工程結構、AndroidGradlePlugin版本、各工具鏈版本等兼容性,補齊更多類型資源間的引用分析,添加額外模塊歸屬信息,最終沉澱為此無用資源檢測功能。
圖注:無用資源分析
無用資源檢測,分析結果示例:
project:app:1.0
|-- array/planets_array
|-- color/white
|-- drawable/fake_drawable
|-- layout/layout_miss_view
|-- raw/app_resource_raw_chinese_text
|-- string/string_resource_chinese_name
|-- xml/app_resource_xml_chinese_text
project:library-aar-1:1.0
|-- layout/layout_contain_merge
|-- string/library_aar_1_name
此外,資源的直接引用關係,也可以輸出到分析結果中:
Resource Reference Graph:
array:planets_array:2130771968 is reachable: false. The references =>
attr:attr_enum:2130837504 is reachable: true. The references =>
referenced by code : [com/example/libraryaar1/CustomImageView (project:library-aar-1:1.0)]
referenced by resource : [layout:layout_use_declare_styleable1:2131099652]
attr:attr_integer:2130837505 is reachable: true. The references =>
referenced by resource : [style:CustomTextStyle:2131361792]
無用資源,考慮到存在間接(動態)引用導致誤檢的問題,因此並沒有進一步形成卡口,而是作為包大小分析結果中,一個可瘦身項來呈現。2020年6月功能上線時,共有1.7萬個無用資源,目前已經降至0.9萬個,存量清理效果顯著。
無用資源治理情況
2.3 缺失類引用
layout中可以聲明自定義view節點,如果這個自定義view對應類,最終不在apk的dex文件中,由於資源編譯的特性,上述情況並不會引發apk構建過程失敗,但是在app運行時,一旦“加載“此layout就會引發異常。上述這種情況,我們稱之為資源的缺失類引用。
資源缺失類引用檢測,列出了問題資源,及其所屬模塊,以及缺失的引用類。示例結果如下:
* [ignored] layout-xxxhdpi/layout_include_layout (project:library-aar-1:1.0)
|-- com.example.libraryaar1.NonExistCustomView
* layout/layout_miss_view (project:app:1.0, project:library-aar-1:1.0)
|-- com.example.myapplication.NonExistView2
|-- com.example.myapplication.NonExistView
與此同時,提供資源名稱顆粒度的忽略名單配置,暫時排除一些二、三方模塊內的問題資源。更近一步,提供選項,當檢測結果不通過時,終止構建過程,形成卡口機制。此項功能,近期剛上線對應卡口,尚未有觸發卡口攔截案例出現,存量30個問題資源,已分發到對應研發團隊。
事實上,layout中的每一個自定義view節點,AAPT在進行處理時,都會生成一條keep規則,這會成為一條無用keep規則,在「向工程腐化開炮:proguard治理」一文中,提到了這種情況。在此,把示例再展示下:
# layout中引用不存在的class,在apk編譯過程中,並不會引發構建失敗,但依然會生成相對應的keep規則。
# 這個layout一旦在運行時被“加載“,那麼會引發Java類找不到的異常。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.example.myapplication.NonExistView
android:id="@+id/main_textview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"/>
</LinearLayout>
# 生成的keep規則為:-keep class com.example.myapplication.NonExistView { <init> ( ... ) ; }
雖然無用keep規則卡口,已經完全包含資源缺失類引用問題,但是二者管控的維度並不一致,因此仍然將資源缺失類引用,作為獨立能力提供。
2.4 硬編碼文本
硬編碼文本,是指直接在資源中編寫的字符串文本。隱私合規檢測機構,會檢測apk中的一些敏感文本,做為隱私合規問題的重點懷疑&驗證點,例如「發票抬頭」、「身份證」等,其中一部分就是來自於資源中的硬編碼文本(另外可能的來源是java代碼、so)。硬編碼文本,存在以下缺點:
- 易冗餘。多處資源使用同一文本時,會導致存在多份此文本;
- 不靈活。當線上版本出現問題時(例如各類運營活動),難以動態修改;
- 低安全。一些敏感信息,如果以明文硬編碼文本形式存在,非常容易被獲取,並用於不正當用途。
對於這類問題,開發了對應檢測能力,可以自定義正則表達式,對上述資源中硬編碼文本進行匹配。檢測結果中,按照模塊、資源進行逐級聚合。支持以下類型資源中的字符串文本:
以所有中文字符檢測為例:
project:app:1.0
|-- array/planets_array
| |-- [text] string-array包含的中文item
|-- raw/app_resource_raw_chinese_text
| |-- [text] <files-path name="我是raw類型xml資源文件中,包含的中文文本" path="game-bundles/" />
|-- string/string_resource_chinese_name
| |-- [text] 我是中文string資源
|-- xml/app_resource_xml_chinese_text
| |-- [text] <files-path name="我是xml資源中的中文文本" path="game-bundles/" />
|-- layout/activity_main
| |-- [text] android:text="你好,世界!" />
project:library-aar-1:1.0
|-- asset/library_aar_1_asset_chinese_text
| |-- [text] 我是包含中文文本的asset資源文件.
目前在優酷,隱私合規相關的一些敏感文本,是一個正在進行的探索方向,由於目前沒有明確規則,因此還沒有實際落地使用。在日常研發過程,對於需要查找特定硬編碼文本的場景,已經能夠起到很好的輔助提效作用。
2.5 治理全景
至此,對於Android資源,進行了較全面有效的防腐化能力建設和治理。最後,給出一份全景圖:
圖注:資源治理全景
還能做些什麼
Android資源,並不會像java代碼那樣多變和複雜,前面這些治理項,已經基本覆蓋絕大部分資源腐化場景,但是Android資源在日常研發過程中,非常容易被忽視:一個字符串、一個顏色/尺寸值、一個屬性值,一個佈局文件,好像每一個都“微不足道”,即使重複定義、即使忘了清理,看起來也沒多大影響。而這,正是資源腐化的可怕之處:單個資源過於“微小”,開發者的專業意識稍有鬆懈,就成了漏網之魚。
能夠進行批量的清理,固然值得稱讚,但是在日常研發的點滴間,能夠時刻堅守工匠精神,降低“腐化”代碼產生,更難能可貴。“千丈之堤,以螻蟻之穴潰;百尺之室,以突隙之煙焚”(韓非子·喻老),與諸君共勉。
【參考文檔】
- 【google】應用資源:https://developer.android.com...
- 【google】AAPT2:https://developer.android.com...
關注【阿里巴巴移動技術】微信公眾號,每週 3 篇移動技術實踐&乾貨給你思考!