下一代Android打包工具:packer-ng-plugin
下一代Android渠道打包工具
最新版本
- v1.0.2 - 2015.12.04 - 兼容productFlavors,完善異常處理
- v1.0.1 - 2015.12.01 - 如果沒有讀取到渠道,默認返回空字符串
- v1.0.0 - 2015.11.30 - 增加Java和Python打包腳本,增加文檔
- v0.9.9 - 2015.11.26 - 測試版發布,支持全新的極速打包方式 </ul>
- 打包時命令行使用
-Pmarket= yourMarketFilePath
指定屬性 - 在
gradle.properties
里加入market=yourMarketFilePath
項目介紹
packer-ng-plugin 是下一代Android渠道打包工具Gradle插件,支持極速打包,1000個渠道包只需要5秒鐘,速度是 gradle-packer-plugin 的1000倍以上,可方便的用于CI系統集成,支持自定義輸出目錄和最終APK文件名,依賴包: com.mcxiaoke.gradle:packer-ng:1.0.+
簡短名:packer
,可以在項目的 build.gradle
中指定使用,還提供了命令行獨立使用的Java和Python腳本。實現原理見本文末尾。
使用指南
build.gradle
修改項目根目錄的 buildscript {
......
dependencies{
// add packer-ng
classpath 'com.mcxiaoke.gradle:packer-ng:1.0.2'
}
}
build.gradle
修改Android模塊的 apply plugin: 'packer'
dependencies {
// add packer-helper
compile 'com.mcxiaoke.gradle:packer-helper:1.0.2'
}
注意:packer-ng
和 packer-helper
的版本號需要保持一致
Java代碼中獲取當前渠道
// 如果沒有使用PackerNg打包添加渠道,默認返回的是""
// com.mcxiaoke.packer.helper.PackerNg
final String market = PackerNg.getMarket(Context)
// 或者使用 PackerNg.getMarket(Context,defaultValue)
// 之后就可以使用了,比如友盟可以這樣設置
AnalyticsConfig.setChannel(market)
渠道打包腳本
可以通過兩種方式指定 market
屬性,根據需要選用:
market是你的渠道名列表文件,market文件是基于項目根目錄的 相對路徑
,假設你的項目位于 ~/github/myapp
你的market文件位于 ~/github/myapp/config/markets.txt
那么參數應該是 -Pmarket=config/markets.txt
,一般建議直接放在項目根目錄,如果market文件參數錯誤或者文件不存在會拋出異常。
渠道名列表文件是純文本文件,每行一個渠道號,列表解析的時候會自動忽略空白行和格式不規范的行,請注意看命令行輸出,渠道名和注釋之間用 #
號分割開,可以沒有注釋,示例:
Google_Play#play store market Gradle_Test#test SomeMarket#some market HelloWorld
渠道打包的Gradle命令行參數格式示例(在項目根目錄執行):
./gradlew -Pmarket=markets.txt clean apkRelease
打包完成后你可以在 ${項目根目錄}/build/archives/
目錄找到最終的渠道包。
任務說明
渠道打包的Gradle Task名字是 apk${buildType}
buildType一般是release,也可以是你自己指定的beta或者someOtherType,使用時首字母需要大寫,例如release的渠道包任務名是 apkRelease
,beta的渠道包任務名是 apkBeta
,其它的以此類推。
注意事項
如果你的項目有多個productFlavors
,默認只會用第一個flavor
生成的APK文件作為打包工具的輸入參數,忽略其它flavor
生成的apk,代碼里用的是 ariant.outputs[0].outputFile
。如果你想指定使用某個flavor來生成渠道包,可以用 apkFlavor1Release
,apkFlavor2Beta
這樣的名字,示例(假設flavor名字是Intel):
./gradlew -Pmarket=markets.txt clean apkIntelRelease
插件配置說明(可選)
packer {
// 指定渠道打包輸出目錄
// archiveOutput = file(new File(project.rootProject.buildDir.path, "archives"))
// 指定渠道打包輸出文件名格式
// 默認是 `${appPkg}-${flavorName}-${buildType}-v${versionName}-${versionCode}`
// archiveNameFormat = ''
}
舉例:假如你的App包名是 com.your.company
,渠道名是 Google_Play
,buildType
是 release
,versionName
是 2.1.15
,versionCode
是 200115
,那么生成的APK的文件名是 com.your.company-Google_Player-release-2.1.15-20015.apk
-
archiveOutput 指定渠道打包輸出的APK存放目錄,默認位于
${項目根目錄}/build/archives
-
archiveNameFormat -
Groovy格式字符串
, 指定渠道打包輸出的APK文件名格式,默認文件名格式是:${appPkg}-${flavorName}-${buildType}-v${versionName}-${versionCode}
,可使用以下變量:- projectName - 項目名字
- appName - App模塊名字
- appPkg -
applicationId
(App包名packageName) - buildType -
buildType
(release/debug/beta等) - flavorName -
flavorName
(對應渠道打包中的渠道名字) - versionName -
versionName
(顯示用的版本號) - versionCode -
versionCode
(內部版本號) - buildTime -
buildTime
(編譯構建日期時間)
命令行打包腳本
如果不想使用Gradle插件,這里還有兩個命令行打包腳本,在項目的 tools
目錄里,分別是 ngpacker-x.x.x-capsule.jar
和 ngpacker.py
,使用命令行打包工具,在Java代碼里仍然是使用packer-helper
包里的 PackerNg.getMarket(Context)
讀取渠道
Java腳本
java -jar ngpacker-x.x.x-capsule.jar release_apk_file market_file
// help: java -jar packer-ng-x.x.x-capsule.jar
Python腳本
python ngpacker.py [file] [market] [output] [-h] [-s] [-t TEST]
// help: python packer-ng.py -h
// python; import ngpacker; help(ngpacker)
不使用Gradle
使用命令行打包腳本,不想添加Gradle依賴的,可以完全忽略Gradle的配置,直接復制 PackerNg.java 到項目中使用即可
實現原理
PackerNg原理
優點
- 使用APK注釋字段保存渠道信息和MAGIC字節,從文件末尾讀取渠道信息,速度快
- 實現為一個Gradle Plugin,支持定制輸出APK的文件名等信息,方便CI集成
- 提供Java版和Python的獨立命令行腳本,不依賴Gradle插件,支持獨立使用
- 由于打包速度極快,單個包只需要5毫秒左右,可用于網站后臺動態生成渠道包
缺點
- 沒有使用Android的productFlavors,無法利用flavors條件編譯的功能
文件格式
Android應用使用的APK文件就是一個帶簽名信息的ZIP文件,根據 ZIP文件格式規范,每個ZIP文件的最后都必須有一個叫 Central Directory Record 的部分,這個CDR的最后部分叫"end of central directory record",這一部分包含一些元數據,它的末尾是ZIP文件的注釋。注釋包含Comment Length和File Comment兩個字段,前者表示注釋內容的長度,后者是注釋的內容,正確修改這一部分不會對ZIP文件造成破壞,利用這個字段,我們可以添加一些自定義的數據,PackerNg項目就是在這里添加和讀取渠道信息。
細節處理
原理很簡單,就是將渠道信息存放在APK文件的注釋字段中,但是實現起來遇到不少坑,測試了好多次。
ZipOutputStream.setComment
FileOutputStream is = new FileOutputStream("demo.apk", true);
ZipOutputStream zos = new ZipOutputStream(is);
zos.setComment("Google_Market");
zos.finish();
zos.close();
ZipFile zipFile=new ZipFile("demo.apk");
System.out.println(zipFile.getComment());
使用Java寫入APK文件注釋雖然可以正常讀取,但是安裝的時候會失敗,錯誤信息是:
adb install -r demo.apk
Failure [INSTALL_FAILED_INVALID_APK]
原因未知,可能Java的Zip實現寫入了某些特殊字符導致APK文件校驗失敗,于是只能放棄這個方法。同樣的功能使用Python測試完全沒有問題,處理后的APK可以正常安裝。
ZipFile.getComment
上面是ZIP文件注釋寫入,使用Java會導致APK文件被破壞,無法安裝。這里是讀取ZIP文件注釋的問題,Java 7里可以使用 zipFile.getComment()
方法直接讀取注釋,非常方便。但是Android系統直到API 19,也就是4.4以上的版本才支持 ZipFile.getComment()
方法。由于要兼容之前的版本,所以這個方法也不能使用。
解決方法
由于使用Java直接寫入和讀取ZIP文件的注釋都不可行,使用Python又不方便與Gradle系統集成,所以只能自己實現注釋的寫入和讀取。實現起來也不復雜,就是為了提高性能,避免讀取整個文件,需要在注釋的最后加入幾個MAGIC字節,這樣從文件的最后開始,讀取很少的幾個字節就可以定位渠道名的位置。
幾個常量定義:
// ZIP文件的注釋最長65535個字節
static final int ZIP_COMMENT_MAX_LENGTH = 65535;
// ZIP文件注釋長度字段的字節數
static final int SHORT_LENGTH = 2;
// 文件最后用于定位的MAGIC字節
static final byte[] MAGIC = new byte[]{0x21, 0x5a, 0x58, 0x4b, 0x21}; //!ZXK!
讀寫注釋
Java版詳細的實現見 PackerNg.java,Python版的實現見 ngpacker.py 。
寫入ZIP文件注釋:
public static void writeZipComment(File file, String comment)
throws IOException {
byte[] data = comment.getBytes(UTF_8);
final RandomAccessFile raf = new RandomAccessFile(file, "rw");
raf.seek(file.length() - SHORT_LENGTH);
// write zip comment length
// (content field length + length field length + magic field length)
writeShort(data.length + SHORT_LENGTH + MAGIC.length, raf);
// write content
writeBytes(data, raf);
// write content length
writeShort(data.length, raf);
// write magic bytes
writeBytes(MAGIC, raf);
raf.close();
}
讀取ZIP文件注釋,有兩個版本的實現,這里使用的是 RandomAccessFile
,另一個版本使用的是 MappedByteBuffer
,經過測試,對于特別長的注釋,使用內存映射文件讀取性能要稍微好一些,對于特別短的注釋(比如渠道名),這個版本反而更快一些。
public static String readZipComment(File file) throws IOException {
RandomAccessFile raf = null;
try {
raf = new RandomAccessFile(file, "r");
long index = raf.length();
byte[] buffer = new byte[MAGIC.length];
index -= MAGIC.length;
// read magic bytes
raf.seek(index);
raf.readFully(buffer);
// if magic bytes matched
if (isMagicMatched(buffer)) {
index -= SHORT_LENGTH;
raf.seek(index);
// read content length field
int length = readShort(raf);
if (length > 0) {
index -= length;
raf.seek(index);
// read content bytes
byte[] bytesComment = new byte[length];
raf.readFully(bytesComment);
return new String(bytesComment, UTF_8);
}
}
} finally {
if (raf != null) {
raf.close();
}
}
return null;
}
讀取APK文件,由于這個庫 packer-helper
需要同時給Gradle插件和Android項目使用,所以不能添加Android相關的依賴,但是又需要讀取自身APK文件的路徑,使用反射實現:
// for android code
private static String getSourceDir(final Object context)
throws ClassNotFoundException,
InvocationTargetException,
IllegalAccessException,
NoSuchFieldException,
NoSuchMethodException {
final Class<?> contextClass = Class.forName("android.content.Context");
final Class<?> applicationInfoClass = Class.forName("android.content.pm.ApplicationInfo");
final Method getApplicationInfoMethod = contextClass.getMethod("getApplicationInfo");
final Object appInfo = getApplicationInfoMethod.invoke(context);
final Field sourceDirField = applicationInfoClass.getField("sourceDir");
return (String) sourceDirField.get(appInfo);
}
Gradle Plugin
這個和舊版插件基本一致,首先是讀取渠道列表文件,保存起來,打包的時候遍歷列表,復制生成的APK文件到臨時文件,給臨時文件寫入渠道信息,然后復制到輸出目錄,文件名可以使用模板定制。主要代碼如下:
// 添加打包用的TASK
def archiveTask = project.task("apk${variant.name.capitalize()}",
type: ArchiveAllApkTask) {
theVariant = variant
theExtension = modifierExtension
theMarkets = markets
dependsOn variant.assemble
}
def buildTypeName = variant.buildType.name
if (variant.name != buildTypeName) {
project.task("apk${buildTypeName.capitalize()}", dependsOn: archiveTask)
}
// 遍歷列表修改APK文件
theMarkets.each { String market ->
String apkName = buildApkName(theVariant, market)
File tempFile = new File(tempDir, apkName)
File finalFile = new File(outputDir, apkName)
tempFile << originalFile.bytes
copyTo(originalFile, tempFile)
PackerNg.Helper.writeMarket(tempFile, market)
if (PackerNg.Helper.verifyMarket(tempFile, market)) {
copyTo(tempFile, finalFile)
}
}
詳細的實現可以查看文件 PackerNgPlugin.groovy 和文件 ArchiveAllApkTask.groovy
同類工具
- gradle-packer-plugin - 舊版渠道打包工具,完全使用Gradle系統實現,能利用Android提供的productFlavors系統的條件編譯功能,無任何兼容性問題,方便集成,但是由于每次都要重新打包,速度比較慢,不適合需要大量打包的情況。(性能:200個渠道包需要一到兩小時)
- Meituan-MultiChannelTool - 使用美團方案的實現,在APK文件的
META-INF
目里增加渠道文件,打包速度也非常快,但讀取時需要遍歷APK文件的數據項,比較慢,而且以后可能遇到兼容性問題 - MultiChannelPackageTool - 將渠道寫入APK文件的注釋,這個項目沒有提供Gradle插件,只有命令行工具,不方便CI集成,使用ZIP文件注釋的思路就是來自此項目
關于作者
聯系方式
- Blog: http://blog.mcxiaoke.com
- Github: https://github.com/mcxiaoke
- Email: github@mcxiaoke.com
開源項目
- Next公共組件庫: https://github.com/mcxiaoke/Android-Next
- Gradle渠道打包: https://github.com/mcxiaoke/gradle-packer-plugin
- EventBus實現xBus: https://github.com/mcxiaoke/xBus
- Rx文檔中文翻譯: https://github.com/mcxiaoke/RxDocs
- MQTT協議中文版: https://github.com/mcxiaoke/mqtt
- 蘑菇飯App: https://github.com/mcxiaoke/minicat
- 飯否客戶端: https://github.com/mcxiaoke/fanfouapp-opensource
- Volley鏡像: https://github.com/mcxiaoke/android-volley
License
Copyright 2014 - 2015 Xiaoke Zhang Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.