Android App瘦身實戰
隨著業務的快速迭代增長,不斷引入新的業務邏輯代碼、圖片資源和第三方SDK等,很多app都麵臨一個一個結果,app越來越大,甚至很多無用的代碼,包體積的增大帶來了很多問題,諸如app啟動更慢,代碼維護越來越困難。公司業務發展到一定程度之後,重構,代碼優化,app瘦身成為不得不做的一個任務。這裏以xx外賣app為例給大家講講app瘦身過程中常用的幾種方法(也都是網上老生常談的)。
apk文件構成
可以看到APK由以下主要部分組成:
文件/目錄 | 描述 |
---|---|
lib/ | 存放庫文件,存放so文件,可能會有armeabi、armeabi-v7a、arm64-v8a、x86、x86_64、mips,大多數情況下隻需要支持armabi與x86的架構即可 |
res/ | 存放資源文件,例如:drawable、layout等等 |
assets/ | 應用程序的資源,應用程序可以使用AssetManager來檢索該資源 |
classes(n).dex | classes文件是Java Class,被DEX編譯後可供Dalvik/ART虛擬機所理解的文件格式 |
resources.arsc | 編譯後的二進製資源文件 |
AndroidManifest.xml | Android的清單文件,用於描述應用程序的名稱、版本、所需權限、注冊的四大組件 |
在充分了解了APK各個組成部分以及它們的作用後,我們針對自身特點進行了分析和優化。下麵將從Zip文件格式、classes.dex、資源文件、resources.arsc等方麵來介紹下優化技巧。
Zip格式優化
通過命令來查看APK文件時會得到以下信息。命令如下:
aapt l -v xxx.apk或unzip -l xxx.apk
通過上圖可以看到APK中很多資源是以Stored來存儲的,根據Zip的文件格式中對壓縮方式的描述Compression_methods可以看出這些文件是沒有壓縮的,那為什麼它們沒有被壓縮呢?從AAPT的源碼中找到以下描述:
/* these formats are already compressed, or don't compress well */
static const char* kNoCompressExt[] = {
".jpg", ".jpeg", ".png", ".gif",
".wav", ".mp2", ".mp3", ".ogg", ".aac",
".mpg", ".mpeg", ".mid", ".midi", ".smf", ".jet",
".rtttl", ".imy", ".xmf", ".mp4", ".m4a",
".m4v", ".3gp", ".3gpp", ".3g2", ".3gpp2",
".amr", ".awb", ".wma", ".wmv", ".webm", ".mkv"
};
上麵的解釋說的很明白,aapt在資源處理時對上述文件後綴類型的資源是不做壓縮的,那是不是可以修改它們的壓縮方式從而達到瘦身的效果呢?
答案是可以的,例如采用7Zip壓縮等等。
為了大家更好的理解Android對資源的打包過程,我們下麵來簡單的分析一下。
aapt資源打包過程
通過上圖可以看到Manifest、Resources、Assets的資源經過AAPT處理後生成R.java、Proguard Configuration、Compiled Resources。其中,Proguard Configuration是AAPT工具為Manifest中聲明的四大組件以及布局文件中(XML layouts)使用的各種Views所生成的ProGuard配置。Compiled Resources是一個Zip格式的文件,這個文件包含了res、AndroidManifest.xml和resources.arsc的文件或文件夾,其實就是APK的“資源包”(res、AndroidManifest.xml和resources.arsc等資源)。
我們可以通過這個文件來修改不同後綴文件資源的壓縮方式來達到瘦身效果的。
在自己的項目中是通過在package${flavorName} Task對resources.arsc進行優化。下麵是部分代碼:
appPlugin.variantManager.variantDataList.each { variantData ->
variantData.outputs.each {
def sourceApFile = it.packageAndroidArtifactTask.getResourceFile();
def destApFile = new File("${sourceApFile.name}.temp", sourceApFile.parentFile);
it.packageAndroidArtifactTask.doFirst {
byte[] buf = new byte[1024 * 8];
ZipInputStream zin = new ZipInputStream(new FileInputStream(sourceApFile));
ZipOutputStream out = new ZipOutputStream(new FileOutputStream(destApFile));
ZipEntry entry = zin.getNextEntry();
while (entry != null) {
String name = entry.getName();
// Add ZIP entry to output stream.
ZipEntry zipEntry = new ZipEntry(name);
if (ZipEntry.STORED == entry.getMethod() && !okayToCompress(entry.getName())) {
zipEntry.setMethod(ZipEntry.STORED)
zipEntry.setSize(entry.getSize())
zipEntry.setCompressedSize(entry.getCompressedSize())
zipEntry.setCrc(entry.getCrc())
} else {
zipEntry.setMethod(ZipEntry.DEFLATED)
...
}
...
out.putNextEntry(zipEntry);
out.closeEntry();
entry = zin.getNextEntry();
}
// Close the streams
zin.close();
out.close();
sourceApFile.delete();
destApFile.renameTo(sourceApFile);
}
}
}
classes.dex的優化
如何優化classes.dex的大小呢?大約有以下幾種套路:
- 保持良好的編程習慣和對包體積敏銳的嗅覺,去除重複或者不用的代碼,慎用第三方庫,選用體積小的第三方SDK。
- 開啟ProGuard,通過使用ProGuard來對代碼進行混淆、優化、壓縮等工作
第一個方案對程序猿的素質要求比較高,項目經驗也很重要,所以因人而異。
壓縮代碼
可以通過開啟ProGuard來實現代碼壓縮,可以在build.gradle文件相應的構建類型中添加:
minifyEnabled true
例如,常見的一段build.gradle腳本。
android {
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile(‘proguard-android.txt'),
'proguard-rules.pro'
}
}
...
}
要想做進一步的代碼壓縮,可嚐試使用位於同一位置的proguard-android-optimize.txt文件。它包括相同的ProGuard規則,但還包括其他在字節碼一級(方法內和方法間)執行分析的優化,以進一步減小APK大小和幫助提高其運行速度。
在Gradle Plugin 2.2.0及以上版本ProGuard的配置文件會自動解壓縮到${rootProject.buildDir}/${AndroidProject.FD_INTERMEDIATES}/proguard-files/目錄下,proguardFiles會從這個目錄來獲取ProGuard配置。
每次執行完ProGuard之後,ProGuard都會在${project.buildDir}/outputs/mapping/${flavorDir}/生成以下文件:
文件名 | 描述 |
---|---|
dump.txt | APK中所有類文件的內部結構 |
mapping.txt | 提供原始與混淆過的類、方法和字段名稱之間的轉換,可以通過proguard.obfuscate.MappingReader來解析 |
seeds.txt | 列出未進行混淆的類和成員 |
usage.txt | 列出從APK移除的代碼 |
資源的優化
對於資源的優化也是最行之有效,最為直觀的優化方案。通過對資源文件的優化,可以大大的減小apk體積大小。
圖片優化
為了支持Android設備DPI的多樣化([l|m|tv|h|x|xx|xxx]dpi)以及用戶對高質量UI的期待,往往在App中使用了大量的圖片以及不同的格式,例如:PNG、JPG 、WebP,那我們該怎麼選擇不同類型的圖片格式呢?
Google I/O 2016大會上推薦使用WebP格式圖片,可以大大減少體積,而顯示又不失真。
通過上圖我們可以看出圖片格式選擇的方法:如果能用VectorDrawable來表示的話優先使用VectorDrawable,如果支持WebP則優先用WebP,而PNG主要用在展示透明或者簡單的圖片,而其它場景可以使用JPG格式。這樣就達到了什麼場景選什麼圖片更好。
矢量圖片
使用矢量圖片能夠有效的減少App中圖片所占用的大小,矢量圖形在Android中表示為VectorDrawable對象。 使用VectorDrawable對象,100字節的文件可以生成屏幕大小的清晰圖像,但係統渲染每個VectorDrawable對象需要大量的時間,較大的圖像需要更長的時間才能出現在屏幕上。 因此隻有在顯示小圖像時才考慮使用矢量圖形。
WebP
如果App的minSdkVersion>=14(Android 4.0+)的話,可以選用WebP格式,因為WebP在同畫質下體積更小。但是Android從4.0才開始WebP的原生支持,但是不支持包含透明度,直到Android 4.2.1+才支持顯示含透明度的WebP。所以為了更好的使用webP格式,我們需要讀係統進行判斷,這裏我寫了一個工具類:
boolean isPNGWebpConvertSupported() {
if (!isWebpConvertEnable()) {
return false
}
// Android 4.0+
return GradleUtils.getAndroidExtension(project).defaultConfig.minSdkVersion.apiLevel >= 14
// 4.0
}
boolean isTransparencyPNGWebpConvertSupported() {
if (!isWebpConvertEnable()) {
return false
}
// Lossless, Transparency, Android 4.2.1+
return GradleUtils.getAndroidExtension(project).defaultConfig.minSdkVersion.apiLevel >= 18
// 4.3
}
def convert() {
String resPath = "${project.buildDir}/${AndroidProject.FD_INTERMEDIATES}/res/merged/${variant.dirName}"
def resDir = new File("${resPath}")
resDir.eachDirMatch(~/drawable[a-z0-9-]*/) { dir ->
FileTree tree = project.fileTree(dir: dir)
tree.filter { File file ->
return (isJPGWebpConvertSupported() && (file.name.endsWith(SdkConstants.DOT_JPG) || file.name.endsWith(SdkConstants.DOT_JPEG))) || (isPNGWebpConvertSupported() && file.name.endsWith(SdkConstants.DOT_PNG) && !file.name.endsWith(SdkConstants.DOT_9PNG))
}.each { File file ->
def shouldConvert = true
if (file.name.endsWith(SdkConstants.DOT_PNG)) {
if (!isTransparencyPNGWebpConvertSupported()) {
shouldConvert = !Imaging.getImageInfo(file).isTransparent()
}
}
if (shouldConvert) {
WebpUtils.encode(project, webpFactorQuality, file.absolutePath, webp)
}
}
}
}
選擇更優的壓縮工具
可以使用pngcrush、pngquant或zopflipng等壓縮工具來減少PNG文件大小,而不會丟失圖像質量。所有這些工具都可以減少PNG文件大小,同時保持圖像質量。
開啟資源壓縮
Android的編譯工具鏈中提供了一款資源壓縮的工具,可以通過該工具來壓縮資源,如果要啟用資源壓縮,可以在build.gradle文件中啟用,例如:
android {
...
buildTypes {
release {
shrinkResources true
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'),
'proguard-rules.pro'
}
}
}
Android構建工具是通過ResourceUsageAnalyzer來檢查哪些資源是無用的,當檢查到無用的資源時會把該資源替換成預定義的版本。關於資源工具壓縮的詳細介紹請查看Shrink Your Code and Resources
如果想知道哪些資源是無用的,可以通過資源壓縮工具的輸出日誌文件${project.buildDir}/outputs/mapping/release/resources.txt來查看。例如:
資源壓縮工具隻是把無用資源替換成預定義較小的版本,那我們如何刪除這些無用資源呢?通常的做法是結合資源壓縮工具的輸出日誌,找到這些資源並把它們進行刪除。
resources.arsc的優化
關於resources.arsc的優化,主要從以下一個方麵來優化:
- 開啟資源混淆;
- 對重複的資源進行優化;
- 對被shrinkResources優化掉的資源進行處理。
資源混淆
這裏推薦使用微信開源的資源混淆庫AndResGuard,具體使用方法請查看安裝包立減1M--微信Android資源混淆打包工具
無用資源優化
在上麵的介紹中,可以通過shrinkResources true來開啟資源壓縮,資源壓縮工具會把無用的資源替換成預定義的版本而不是移除,如果采用人工移除的方式會帶來後期的維護成本,這裏筆者采用了一種比較取巧的方式,在Android構建工具執行package${flavorName}Task之前通過修改Compiled Resources來實現自動去除無用資源。
使用流程如下:
- 收集資源包(Compiled Resources的簡稱)中被替換的預定義版本的資源名稱,通過查看資源包(Zip格式)中每個ZipEntry的CRC-32 checksum來尋找被替換的預定義資源,預定義資源的CRC-32定義在ResourceUsageAnalyzer,下麵是它們的定義。例如:
// A 1x1 pixel PNG of type BufferedImage.TYPE_BYTE_GRAY
public static final long TINY_PNG_CRC = 0x88b2a3b0L;
// A 3x3 pixel PNG of type BufferedImage.TYPE_INT_ARGB with 9-patch markers
public static final long TINY_9PNG_CRC = 0x1148f987L;
// The XML document <x/> as binary-packed with AAPT
public static final long TINY_XML_CRC = 0xd7e65643L;
2 通過android-chunk-utils把resources.arsc中對應的定義移除;
- 刪除資源包中對應的資源文件。
重複資源優化
產生重複資源的原因是不同的人,在開發的時候沒有注意資源的可重用,對於人數比較少,規範到位是可以避免的,但是對於業務比較多,就會造成資源的重複。那麼,針對這種問題,我們該怎麼優化呢?
具體步驟如下:
- 通過資源包中的每個ZipEntry的CRC-32 checksum來篩選出重複的資源;
- 通過android-chunk-utils修改resources.arsc,把這些重複的資源都重定向到同一個文件上;
- 把其它重複的資源文件從資源包中刪除。
工具類代碼片段:
variantData.outputs.each {
def apFile = it.packageAndroidArtifactTask.getResourceFile();
it.packageAndroidArtifactTask.doFirst {
def arscFile = new File(apFile.parentFile, "resources.arsc");
JarUtil.extractZipEntry(apFile, "resources.arsc", arscFile);
def HashMap<String, ArrayList<DuplicatedEntry>> duplicatedResources = findDuplicatedResources(apFile);
removeZipEntry(apFile, "resources.arsc");
if (arscFile.exists()) {
FileInputStream arscStream = null;
ResourceFile resourceFile = null;
try {
arscStream = new FileInputStream(arscFile);
resourceFile = ResourceFile.fromInputStream(arscStream);
List<Chunk> chunks = resourceFile.getChunks();
HashMap<String, String> toBeReplacedResourceMap = new HashMap<String, String>(1024);
// 處理arsc並刪除重複資源
Iterator<Map.Entry<String, ArrayList<DuplicatedEntry>>> iterator = duplicatedResources.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, ArrayList<DuplicatedEntry>> duplicatedEntry = iterator.next();
// 保留第一個資源,其他資源刪除掉
for (def index = 1; index < duplicatedEntry.value.size(); ++index) {
removeZipEntry(apFile, duplicatedEntry.value.get(index).name);
toBeReplacedResourceMap.put(duplicatedEntry.value.get(index).name, duplicatedEntry.value.get(0).name);
}
}
for (def index = 0; index < chunks.size(); ++index) {
Chunk chunk = chunks.get(index);
if (chunk instanceof ResourceTableChunk) {
ResourceTableChunk resourceTableChunk = (ResourceTableChunk) chunk;
StringPoolChunk stringPoolChunk = resourceTableChunk.getStringPool();
for (def i = 0; i < stringPoolChunk.stringCount; ++i) {
def key = stringPoolChunk.getString(i);
if (toBeReplacedResourceMap.containsKey(key)) {
stringPoolChunk.setString(i, toBeReplacedResourceMap.get(key));
}
}
}
}
} catch (IOException ignore) {
} catch (FileNotFoundException ignore) {
} finally {
if (arscStream != null) {
IOUtils.closeQuietly(arscStream);
}
arscFile.delete();
arscFile << resourceFile.toByteArray();
addZipEntry(apFile, arscFile);
}
}
}
}
通過這種方式可以有效減少重複資源對包體大小的影響,同時這種操作方式對各業務團隊透明。
最後更新:2017-04-22 09:12:45