編輯:關於Android編程
最近攜程開源了一套動態加載的框架,總的來說,該框架和OpenAtlas還是有一定的相似之處的,比如資源的分區。此外該框架也支持熱修復。個人覺得該框架中攜程做的比較多的應該在打包語句的編寫上面,這篇文章主要用於記錄自己學習該框架的一個過程。在攜程的github上,給出的打包方法是命令行執行gradle,如下
git clone https://github.com/CtripMobile/DynamicAPK.git
cd DynamicAPK/
gradlew assembleRelease bundleRelease repackAll
該命令行中執行打包的語句gradlew assembleRelease bundleRelease repackAll,之後就會在對應目錄下生成/build-outputs/appname-release-final.apk文件,這條打包語句可以分解為三條語句依次執行,即gradlew assembleRelease、gradlew bundleRelease、gradlew repackAll,我們依次來看這三個命令到底做了什麼。
該命令定義在sample模塊的build.gradle文件中
//打包後產出物復制到build-outputs目錄。apk、manifest、mapping
task copyReleaseOutputs(type:Copy){
from ($buildDir/outputs/apk/sample-release.apk) {
rename 'sample-release.apk', 'demo-base-release.apk'
}
from $buildDir/intermediates/manifests/full/release/AndroidManifest.xml
from ($buildDir/outputs/mapping/release/mapping.txt) {
rename 'mapping.txt', 'demo-base-mapping.txt'
}
into new File(rootDir, 'build-outputs')
}
assembleRelease<<{
copyReleaseOutputs.execute()
}
從上面的語句看到,在執行完assembleRelease的時候,還執行了copyReleaseOutputs這個task,而這個task所做的就是將sample目錄下的build目錄中生成的部分文件拷貝到build-outputs目錄中
第一個文件是生成的apk文件,並對其進行了重命名;該文件用於後續插件打包的時候資源的引用等。 第二個文件是android的清單文件AndroidManifest.xml,直接復制不進行重命名; 第三個文件是mapping.txt文件,並對其進行了重名名。其中第三個文件是和代碼混淆相關的,如果沒有開啟代碼混淆,該文件是不存在的。該task執行後,目錄中生成的文件如圖所示,其中mapping.txt文件的存在是因為我開啟了混淆。
vcjnz8I8L3A+DQo8cHJlIGNsYXNzPQ=="brush:java;">
buildTypes {
...
release {
...
minifyEnabled true
...
}
}
之後執行的就是bundleRelease
task bundleRelease (type:Zip,dependsOn:['compileRelease','aaptRelease','dexRelease']){
inputs.file $buildDir/intermediates/dex/${project.name}_dex.zip
inputs.file $buildDir/intermediates/res/resources.zip
outputs.file ${rootDir}/build-outputs/${apkName}.so
archiveName = ${apkName}.so
destinationDir = file(${rootDir}/build-outputs)
duplicatesStrategy = 'fail'
from zipTree($buildDir/intermediates/dex/${project.name}_dex.zip)
from zipTree($buildDir/intermediates/res/resources.zip)
}
該task會生成插件的相關文件到build-outputs目錄,該目錄在會事先創建好,首先會在插件模塊的build目錄中將dex.zip和resources.zip壓縮文件中(這兩個文件的生成見下面的task的分析)的文件作為輸入文件,重新壓縮為一個so文件,so的名字為包名.so,其中包名中的點修改為了下劃線,見下圖
該task需要依賴其他三個Task,依次為aaptRelease、compileRelease、dexRelease
if( module.@packageName==${packageName}) {
resourceId=module.@resourceId
println find packageName: + module.@packageName + ,resourceId: + resourceId
}
}
def argv = []
argv << 'package' //打包
argv << -v
argv << '-f' //強制覆蓋已有文件
argv << -I
argv << $sdk.androidJar //添加一個已有的固化jar包
argv << '-I'
argv << ${rootDir}/build-outputs/demo-base-release.apk
argv << '-M'
argv << $projectDir/AndroidManifest.xml //指定manifest文件
argv << '-S'
argv << $projectDir/res //res目錄
argv << '-A'
argv << $projectDir/assets //assets目錄
argv << '-m' //make package directories under location specified by -J
argv << '-J'
argv << $buildDir/gen/r //哪裡輸出R.java定義
argv << '-F'
argv << $buildDir/intermediates/res/resources.zip //指定apk的輸出位置
argv << '-G' //-G A file to output proguard options into.
argv << $buildDir/intermediates/res/aapt-rules.txt
// argv << '--debug-mode' //manifest的application元素添加android:debuggable=true
argv << '--custom-package' //指定R.java生成的package包名
argv << ${packageName}
argv << '-0' //指定哪些後綴名不會被壓縮
argv << 'apk'
argv << '--public-R-path'
argv << ${rootDir}/sample/build/generated/source/r/release/ctrip/android/sample/R.java
argv << '--apk-module'
argv << $resourceId
args = argv
} data-snippet-id=ext.d44f64e4ac50b2acc8c1ccd2ae41d5ef data-snippet-saved=false data-csrftoken=YvivjwzW-3pqMJ0IR7WfTXK1Nv488i9Hbv_c data-codota-status=done>//初始化,確保必要目錄都存在
task init << {
new File(rootDir, 'build-outputs').mkdirs()
buildDir.mkdirs()
new File(buildDir, 'gen/r').mkdirs()
new File(buildDir, 'intermediates').mkdirs()
new File(buildDir, 'intermediates/classes').mkdirs()
new File(buildDir, 'intermediates/classes-obfuscated').mkdirs()
new File(buildDir, 'intermediates/res').mkdirs()
new File(buildDir, 'intermediates/dex').mkdirs()
}
task aaptRelease (type: Exec,dependsOn:'init'){
inputs.file $sdk.androidJar
inputs.file ${rootDir}/build-outputs/demo-base-release.apk
inputs.file $projectDir/AndroidManifest.xml
inputs.dir $projectDir/res
inputs.dir $projectDir/assets
inputs.file ${rootDir}/sample/build/generated/source/r/release/ctrip/android/sample/R.java
outputs.dir $buildDir/gen/r
outputs.file $buildDir/intermediates/res/resources.zip
outputs.file $buildDir/intermediates/res/aapt-rules.txt
workingDir buildDir
executable sdk.aapt
def resourceId=''
def parseApkXml=(new XmlParser()).parse(new File(rootDir,'apk_module_config.xml'))
parseApkXml.Module.each{ module->
if( module.@packageName==${packageName}) {
resourceId=module.@resourceId
println find packageName: + module.@packageName + ,resourceId: + resourceId
}
}
def argv = []
argv << 'package' //打包
argv << -v
argv << '-f' //強制覆蓋已有文件
argv << -I
argv << $sdk.androidJar //添加一個已有的固化jar包
argv << '-I'
argv << ${rootDir}/build-outputs/demo-base-release.apk
argv << '-M'
argv << $projectDir/AndroidManifest.xml //指定manifest文件
argv << '-S'
argv << $projectDir/res //res目錄
argv << '-A'
argv << $projectDir/assets //assets目錄
argv << '-m' //make package directories under location specified by -J
argv << '-J'
argv << $buildDir/gen/r //哪裡輸出R.java定義
argv << '-F'
argv << $buildDir/intermediates/res/resources.zip //指定apk的輸出位置
argv << '-G' //-G A file to output proguard options into.
argv << $buildDir/intermediates/res/aapt-rules.txt
// argv << '--debug-mode' //manifest的application元素添加android:debuggable=true
argv << '--custom-package' //指定R.java生成的package包名
argv << ${packageName}
argv << '-0' //指定哪些後綴名不會被壓縮
argv << 'apk'
argv << '--public-R-path'
argv << ${rootDir}/sample/build/generated/source/r/release/ctrip/android/sample/R.java
argv << '--apk-module'
argv << $resourceId
args = argv
}
可以看到輸出了一個resources.zip文件,這個文件就是bundleRelease 中用到的壓縮文件之一,總的來說該task就是拼接命令行參數生成文件。
aaptRelease是對插件資源文件的編譯,依賴於aapt命令行工具,在了解該Task之前,需要了解一下該命令的一些參數。
-I add an existing package to base include set這個參數可以在依賴路徑中追加一個已經存在的package。在Android中,資源的編譯也需要依賴,最常用的依賴就是SDK自帶的android.jar本身。打開android.jar可以看到,其實不是一個普通的jar包,其中不但包含了已有SDK類庫class,還包含了SDK自帶的已編譯資源以及資源索引表resources.arsc文件。在日常的開發中,我們也經常通過@android:color/opaque_red形式來引用SDK自帶資源。這一切都來自於編譯過程中aapt對android.jar的依賴引用。同理,我們也可以使用這個參數引用一個已存在的apk包作為依賴資源參與編譯。
-G A file to output proguard options into.資源編譯中,對組件的類名、方法引用會導致運行期反射調用,所以這一類符號量是不能在代碼混淆階段被混淆或者被裁減掉的,否則等到運行時會找不到布局文件中引用到的類和方法。-G方法會導出在資源編譯過程中發現的必須keep的類和接口,它將作為追加配置文件參與到後期的混淆階段中。
-J specify where to output R.java resource constant definitions在Android中,所有資源會在Java源碼層面生成對應的常量ID,這些ID會記錄到R.java文件中,參與到之後的代碼編譯階段中。在R.java文件中,Android資源在編譯過程中會生成所有資源的ID,作為常量統一存放在R類中供其他代碼引用。在R類中生成的每一個int型四字節資源ID,實際上都由三個字段組成。第一字節代表了Package,第二字節為分類,三四字節為類內ID。
在對插件的編譯過程中,攜程主要用了三個參數。其中也不乏攜程自己改裝aapt增加的參數。如下
使用-I參數對宿主的apk進行引用。據此,插件的資源、xml布局中就可以使用宿主的資源和控件、布局類了。
為aapt增加–apk-module參數。資源ID其實有一個PackageID的內部字段。我們為每個插件工程指定獨特的PackageID字段,這樣根據資源ID就很容易判明,此資源需要從哪個插件apk中去查找並加載了。
為aapt增加–public-R-path參數。按照對android.jar包中資源使用的常規手段,引用系統資源可使用它的R類的全限定名android.R來引用具體ID,以便和當前項目中的R類區分。插件對於宿主的資源引用,當然也可以使用base.package.name.R來完成。但由於歷史原因,各子BU的“插件”代碼是從主app中解耦獨立出去的,資源引用還是直接使用當前工程的R。如果改為標准模式,則當前大量遺留代碼中R都需要酌情改為base.R,工程量大並且容易出錯,未來對bu開發人員的使用也有點不夠“透明”。因此我們在設計上做了讓步,額外增加–public-R-path參數,為aapt指明了base.R的位置,讓它在編譯期間把base的資源ID定義在插件的R類中完整復制一份,這樣插件工程即可和之前一樣,完全不用在乎資源來自於宿主或者自身,直接使用即可。當然這樣做帶來的副作用就是宿主和插件的資源不應有重名,這點我們通過開發規范來約束,相對比較容易理解一些。
了解了這麼一些基礎的概念之後,回頭再來看看該task所做的工作。首先調用了task init進行一些目錄的創建,然後引入創建apk資源文件所有必要的文件,再通過檢查apk_module_config.xml文件,找到對應包名的resourceId,該文件的定義如下
data-snippet-id=ext.390cbb0be7f7f8db4317a070cfae9f36 data-snippet-saved=false data-codota-status=done>
之後做的就是拼接命令行語句,執行生成資源就可以了。最終的產物就是resources.zip
compileRelease這個task的作用就是編譯java文件,會指定classpath目錄以及目標目錄等相關信息。
task compileRelease(type: JavaCompile,dependsOn:'aaptRelease') {
inputs.file $sdk.androidJar
inputs.files fileTree(${projectDir}/libs).include('*.jar')
inputs.file ${rootDir}/sample/build/intermediates/classes-proguard/release/classes.jar
inputs.files fileTree($projectDir/src).include('**/*.java')
inputs.files fileTree($buildDir/gen/r).include('**/*.java')
outputs.dir $buildDir/intermediates/classes
sourceCompatibility = '1.6'
targetCompatibility = '1.6'
classpath = files(
${sdk.androidJar},
${sdk.apacheJar},
fileTree(${projectDir}/libs).include('*.jar'),
${rootDir}/sample/build/intermediates/classes-proguard/release/classes.jar
)
destinationDir = file($buildDir/intermediates/classes)
dependencyCacheDir = file(${buildDir}/dependency-cache)
source = files(fileTree($projectDir/src).include('**/*.java'),
fileTree($buildDir/gen/r).include('**/*.java'))
options.encoding = 'UTF-8'
}
最終的生成文件會在build/intermediates/classes中
dexRelease這個task的作用就是根據compileRelease生成的classes文件,調用dex工具打包成android專用的dex文件。
task dexRelease (type:Exec){
inputs.file ${buildDir}/intermediates/classes
outputs.file ${buildDir}/intermediates/dex/${project.name}_dex.zip
workingDir buildDir
executable sdk.dex
def argv = []
argv << '--dex'
argv << --output=${buildDir}/intermediates/dex/${project.name}_dex.zip
argv << ${buildDir}/intermediates/classes
args = argv
}
這個task輸出了一個dex.zip,也是bundleRelease這個task中用到的一個壓縮包之一。
這個task主要是調用了其他5個task
task repackAll(dependsOn: ['reload','resign','repack','realign','concatMappings'])
下面來一一分析這幾個task
reload的作用就是將最開始生成的宿主文件的apk的assets目錄中,添加插件so,而so正是前面幾個task生成的插件so文件,最終的產物是demo-release-reloaded.apk這個文件
//base apk的assets中填充各子apk
//輸入:Ctrip-base-release.apk
//輸出:Ctrip-release-reloaded.apk
task reload(type:Zip){
inputs.file $rootDir/build-outputs/demo-base-release.apk
inputs.files fileTree(new File(rootDir,'build-outputs')).include('*.so')
outputs.file $rootDir/build-outputs/demo-release-reloaded.apk
into 'assets/baseres/',{
from fileTree(new File(rootDir,'build-outputs')).include('*.so')
}
from zipTree($rootDir/build-outputs/demo-base-release.apk), {
exclude('**/META-INF/*.SF')
exclude('**/META-INF/*.RSA')
}
destinationDir file($rootDir/build-outputs/)
archiveName 'demo-release-reloaded.apk'
}
apk文件發生了改變,需要對其進行重新簽名,resign這個task的目的就是這個,調用命令行簽名工具,添加證書的信息進行簽名,但是在簽名前會進行一次壓縮,repack 這個task就是進行這個操作,最後輸出的是demo-release-repacked.apk,打包完畢後便會進行簽名的操作,也就是resign這個task所做的工作
//對apk重新壓縮,調整各文件壓縮比到正確
//輸入:Ctrip-release-reloaded.apk
//輸出:Ctrip-release-repacked.apk
task repack (dependsOn: 'reload') {
inputs.file $rootDir/build-outputs/demo-release-reloaded.apk
outputs.file $rootDir/build-outputs/demo-release-repacked.apk
doLast{
println release打包之後,重新壓縮一遍,以壓縮resources.arsc
def oldApkFile = file($rootDir/build-outputs/demo-release-reloaded.apk)
assert oldApkFile != null : 沒有找到release包!
def newApkFile = new File(oldApkFile.parentFile, 'demo-release-repacked.apk')
//重新打包
repackApk(oldApkFile.absolutePath, newApkFile.absolutePath)
assert newApkFile.exists() : 沒有找到重新壓縮的release包!
}
}
//對apk重簽名
//輸入:Ctrip-release-repacked.apk
//輸出:Ctrip-release-resigned.apk
task resign(type:Exec,dependsOn: 'repack'){
inputs.file $rootDir/build-outputs/demo-release-repacked.apk
outputs.file $rootDir/build-outputs/demo-release-resigned.apk
workingDir $rootDir/build-outputs
executable ${System.env.'JAVA_HOME'}/bin/jarsigner
def argv = []
argv << '-verbose'
argv << '-sigalg'
argv << 'SHA1withRSA'
argv << '-digestalg'
argv << 'SHA1'
argv << '-keystore'
argv << $rootDir/demo.jks
argv << '-storepass'
argv << '123456'
argv << '-keypass'
argv << '123456'
argv << '-signedjar'
argv << 'demo-release-resigned.apk'
argv << 'demo-release-repacked.apk'
argv << 'demo'
args = argv
}
簽名完畢後會輸出簽名後的文件demo-release-resigned.apk
而repack這個task最終調用的是repackApk重寫進行壓縮打包的
if(entryIn.directory){
println ${entryIn.name} is a directory
}
else{
def entryOut = new ZipEntry(entryIn.name)
def dotPos = entryIn.name.lastIndexOf('.')
def ext = (dotPos >= 0) ? entryIn.name.substring(dotPos) :
def isRes = entryIn.name.startsWith('res/')
if(isRes && ext in noCompressExt){
entryOut.method = ZipEntry.STORED
entryOut.size = entryIn.size
entryOut.compressedSize = entryIn.size
entryOut.crc = entryIn.crc
}
else{
entryOut.method = ZipEntry.DEFLATED
}
zos.putNextEntry(entryOut)
zos << zipFile.getInputStream(entryIn)
zos.closeEntry()
}
}
zos.finish()
zos.close()
zipFile.close()
} data-snippet-id=ext.a351408c11907042f6379e18d8366690 data-snippet-saved=false data-csrftoken=CFCj3qB4-eUeOg6hcI2TJYMr4jASCdmeRJK4 data-codota-status=done>import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
// 打包過程中很多手工zip過程:
// 1,為了壓縮resources.arsc文件而對標准產出包重新壓縮
// 2,以及各子apk的純手打apk包
// 但對於音頻等文件,壓縮會導致資源加載報異常
// 重新打包方法,使用STORED過濾掉不應該壓縮的文件們
// 後綴名列表來自於android源碼
def repackApk(originApk, targetApk){
def noCompressExt = [.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]
ZipFile zipFile = new ZipFile(originApk)
ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(targetApk)))
zipFile.entries().each{ entryIn ->
if(entryIn.directory){
println ${entryIn.name} is a directory
}
else{
def entryOut = new ZipEntry(entryIn.name)
def dotPos = entryIn.name.lastIndexOf('.')
def ext = (dotPos >= 0) ? entryIn.name.substring(dotPos) :
def isRes = entryIn.name.startsWith('res/')
if(isRes && ext in noCompressExt){
entryOut.method = ZipEntry.STORED
entryOut.size = entryIn.size
entryOut.compressedSize = entryIn.size
entryOut.crc = entryIn.crc
}
else{
entryOut.method = ZipEntry.DEFLATED
}
zos.putNextEntry(entryOut)
zos << zipFile.getInputStream(entryIn)
zos.closeEntry()
}
}
zos.finish()
zos.close()
zipFile.close()
}
簽名完畢後會對該apk進行4K對齊操作。
//重新對jar包做對齊操作
//輸入:Ctrip-release-resigned.apk
//輸出:Ctrip-release-final.apk
task realign (dependsOn: 'resign') {
inputs.file $rootDir/build-outputs/demo-release-resigned.apk
outputs.file $rootDir/build-outputs/demo-release-final.apk
doLast{
println '重新zipalign,還可以加大壓縮率!'
def oldApkFile = file($rootDir/build-outputs/demo-release-resigned.apk)
assert oldApkFile != null : 沒有找到release包!
def newApkFile = new File(oldApkFile.parentFile,'demo-release-final.apk')
def cmdZipAlign = getZipAlignPath()
def argv = []
argv << '-f' //overwrite existing outfile.zip
// argv << '-z' //recompress using Zopfli
argv << '-v' //verbose output
argv << '4' //alignment in bytes, e.g. '4' provides 32-bit alignment
argv << oldApkFile.absolutePath
argv << newApkFile.absolutePath
project.exec {
commandLine cmdZipAlign
args argv
}
assert newApkFile.exists() : 沒有找到重新zipalign的release包!
}
}
最後還有一個task,就是concatMappings,這個task很簡單,做的就是合並一下mapping文件。
/**
* 用來連接文件的task
*/
class ConcatFiles extends DefaultTask {
@InputFiles
FileCollection sources
@OutputFile
File target
@TaskAction
void concat() {
File tmp = File.createTempFile('concat', null, target.getParentFile())
tmp.withWriter { writer ->
sources.each { file ->
file.withReader { reader ->
writer << reader
}
}
}
target.delete()
tmp.renameTo(target)
}
}
//合並base和所有模塊的mapping文件
task concatMappings(type: ConcatFiles){
sources = fileTree(new File(rootDir,'build-outputs')).include('*mapping.txt')
target = new File(rootDir,'build-outputs/demo-mapping-final.txt')
}
最終repackAll這個task的產物如下
Android 圖形特效
現在適配微信版本更加容易了,只需要替換一個Recourse-ID即可可以知道對方發的是小視頻還是語音,並獲取秒數。可以區分聊天信息中的圖片或者表情實現效果:實時監聽當前聊
需要用到的資源再網上可以下載到Java SDK,node.js,apache-ant,ADT(google提供的android開發工具,其實主要是需要android SD
android 6.0權限全面詳細分析和解決方案Marshmallow版本權限修改 android的權限系統一直是首要的安全概念,因為這些權限只在安裝的時候被詢問一次。