無侵入引入Flutter模組

奮鬥的Leo發表於2019-08-22

前言

Flutter 作為當下比較流行的技術,不少公司已經開始在原生專案中接入它,但這也帶來了一些問題:

  • Flutter SDK 問題,在 Android 中,Flutter 的程式碼和 Framework 會被編譯成產物,而且 debug 和 release 生成的產物也是不太一樣的。要編譯就需要有 SDK,這意味著其他成員也需要下載 Flutter SDK,即使他不需要開發 Flutter 模組,還有 Flutter 版本的管理也是一個問題,不過這個已經有解決方案了。
  • Android 和 iOS 專案需要共用一套 Flutter 程式碼,這就需要用合適的方式去管理 Flutter 模組。

文章基於 v1.5.4-hotfix.2 Flutter SDK 版本

Flutter的接入

要優化它,就需要先了解它。以 Android 為例,要接入 Flutter 很方便,首先在 settings.gradle 中:

def flutterProjectRoot = rootProject.projectDir.parentFile.toPath()

def plugins = new Properties()
def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins')
if (pluginsFile.exists()) {
    pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) }
}

plugins.each { name, path ->
    def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile()
    include ":$name"
    project(":$name").projectDir = pluginDirectory
}
複製程式碼

這裡會將 Flutter 所依賴的第三方外掛,include 到我們專案中,而相關的配置就記錄在 .flutter-plugins 中。接著在 app 模組下的 build.gradle 中:

apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
複製程式碼

flutter.gradle 這個檔案在 Flutter SDK 目錄中,我們上面說到編譯成產物的操作就是在這個指令碼中定義的。所以我們注重看下這個檔案:

apply plugin: FlutterPlugin
複製程式碼

FlutterPlugin 是一個自定義的 Gradle Plugin,而且也是定義在這個檔案中的。

project.android.buildTypes {                            
    profile {                                           
        initWith debug                                  
        if (it.hasProperty('matchingFallbacks')) {      
            matchingFallbacks = ['debug', 'release']    
        }                                               
    }                                                   
    dynamicProfile {                                    
        initWith debug                                  
        if (it.hasProperty('matchingFallbacks')) {      
            matchingFallbacks = ['debug', 'release']    
        }                                               
    }                                                   
    dynamicRelease {                                    
        initWith debug                                  
        if (it.hasProperty('matchingFallbacks')) {      
            matchingFallbacks = ['debug', 'release']    
        }                                               
    }                                                   
}                                                       
複製程式碼

除了預設的 debug 和 release 之外,Flutter 會定義 profile、dynamicProfile、dynamicRelease 這三種 buildType,這裡需要注意下,如果專案已經定義了同名的 buildType 的話。matchingFallbacks 表示如果引用的模組中不存在相同的 buildType,則使用這些替補選項。

if (project.hasProperty('localEngineOut')) {
 //...
}
複製程式碼

localEngineOut 可以用於指定特定的 engine 目錄,預設用 SDK 中的,如果自己重新編譯了 engine,可以用這個選項來指向。具體可見:Flutter-Engine-編譯指北

Path baseEnginePath = Paths.get(flutterRoot.absolutePath, "bin", "cache", "artifacts", "engine")                              
String targetArch = 'arm'                                                                                                     
if (project.hasProperty('target-platform') &&                                                                                 
    project.property('target-platform') == 'android-arm64') {                                                                 
  targetArch = 'arm64'                                                                                                        
}                                                                                                                             
debugFlutterJar = baseEnginePath.resolve("android-${targetArch}").resolve("flutter.jar").toFile()                             
profileFlutterJar = baseEnginePath.resolve("android-${targetArch}-profile").resolve("flutter.jar").toFile()                   
releaseFlutterJar = baseEnginePath.resolve("android-${targetArch}-release").resolve("flutter.jar").toFile()                   
dynamicProfileFlutterJar = baseEnginePath.resolve("android-${targetArch}-dynamic-profile").resolve("flutter.jar").toFile()    
dynamicReleaseFlutterJar = baseEnginePath.resolve("android-${targetArch}-dynamic-release").resolve("flutter.jar").toFile()    
if (!debugFlutterJar.isFile()) {                                                                                              
    project.exec {                                                                                                            
        executable flutterExecutable.absolutePath                                                                             
        args "--suppress-analytics"                                                                                           
        args "precache"                                                                                                       
    }                                                                                                                         
    if (!debugFlutterJar.isFile()) {                                                                                          
        throw new GradleException("Unable to find flutter.jar in SDK: ${debugFlutterJar}")                                    
    }                                                                                                                         
}                                                                                                                             
                                                                                                                              
// Add x86/x86_64 native library. Debug mode only, for now.                                                                   
flutterX86Jar = project.file("${project.buildDir}/${AndroidProject.FD_INTERMEDIATES}/flutter/flutter-x86.jar")                
Task flutterX86JarTask = project.tasks.create("${flutterBuildPrefix}X86Jar", Jar) {                                           
    destinationDir flutterX86Jar.parentFile                                                                                   
    archiveName flutterX86Jar.name                                                                                            
    from("${flutterRoot}/bin/cache/artifacts/engine/android-x86/libflutter.so") {                                             
        into "lib/x86"                                                                                                        
    }                                                                                                                         
    from("${flutterRoot}/bin/cache/artifacts/engine/android-x64/libflutter.so") {                                             
        into "lib/x86_64"                                                                                                     
    }                                                                                                                         
}                                                                                                                             
// Add flutter.jar dependencies to all <buildType>Api configurations, including custom ones                                   
// added after applying the Flutter plugin.                                                                                   
project.android.buildTypes.each { addFlutterJarApiDependency(project, it, flutterX86JarTask) }                                
project.android.buildTypes.whenObjectAdded { addFlutterJarApiDependency(project, it, flutterX86JarTask) }                     
複製程式碼

這裡的程式碼看起來很長,其實做的事情就是一件,新增 flutter.jar 依賴,不同的 buildType 新增不同的版本,debug 模式額外增加 x86/x86_64 架構的版本。

project.extensions.create("flutter", FlutterExtension)   
project.afterEvaluate this.&addFlutterTask               
複製程式碼

首先新增一個 FlutterExtension 配置塊,可選的配置有 source 和 target,用於指定編寫的 Flutter 程式碼目錄和執行 Flutter 程式碼的入口 dart 檔案,預設為 lib/main.dart

afterEvaluate 鉤子上新增一個執行方法:addFlutterTask。

verbosefilesystem-rootsfilesystem-scheme 這些一些額外可選的引數,這裡我們先不關心。

if (project.android.hasProperty("applicationVariants")) {   
    project.android.applicationVariants.all addFlutterDeps  
} else {                                                    
    project.android.libraryVariants.all addFlutterDeps      
}                                                           
複製程式碼

存在 applicationVariants 屬性表示當前接入 Flutter 的模組是使用 com.android.applicationapplicationVariantslibraryVariants 都是表示當前模組的構建變體,addFlutterDeps 是一個閉包,這裡的意思是,遍歷所有變體,呼叫 addFlutterDeps。

def addFlutterDeps = { variant ->                                                                                                        
    String flutterBuildMode = buildModeFor(variant.buildType)                                                                            
    if (flutterBuildMode == 'debug' && project.tasks.findByName('${flutterBuildPrefix}X86Jar')) {                                        
        Task task = project.tasks.findByName("compile${variant.name.capitalize()}JavaWithJavac")                                         
        if (task) {                                                                                                                      
            task.dependsOn project.flutterBuildX86Jar                                                                                    
        }                                                                                                                                
        task = project.tasks.findByName("compile${variant.name.capitalize()}Kotlin")                                                     
        if (task) {                                                                                                                      
            task.dependsOn project.flutterBuildX86Jar                                                                                    
        }                                                                                                                                
    }                                                                                                                                    
                                                                                                                                         
    FlutterTask flutterTask = project.tasks.create(name: "${flutterBuildPrefix}${variant.name.capitalize()}", type: FlutterTask) {       
        flutterRoot this.flutterRoot                                                                                                     
        flutterExecutable this.flutterExecutable                                                                                         
        buildMode flutterBuildMode                                                                                                       
        localEngine this.localEngine                                                                                                     
        localEngineSrcPath this.localEngineSrcPath                                                                                       
        targetPath target                                                                                                                
        verbose verboseValue                                                                                                             
        fileSystemRoots fileSystemRootsValue                                                                                             
        fileSystemScheme fileSystemSchemeValue                                                                                           
        trackWidgetCreation trackWidgetCreationValue                                                                                     
        compilationTraceFilePath compilationTraceFilePathValue                                                                           
        createPatch createPatchValue                                                                                                     
        buildNumber buildNumberValue                                                                                                     
        baselineDir baselineDirValue                                                                                                     
        buildSharedLibrary buildSharedLibraryValue                                                                                       
        targetPlatform targetPlatformValue                                                                                               
        sourceDir project.file(project.flutter.source)                                                                                   
        intermediateDir project.file("${project.buildDir}/${AndroidProject.FD_INTERMEDIATES}/flutter/${variant.name}")                   
        extraFrontEndOptions extraFrontEndOptionsValue                                                                                   
        extraGenSnapshotOptions extraGenSnapshotOptionsValue                                                                             
    }                                                                                                                                    
                                                                                                                                         
    // We know that the flutter app is a subproject in another Android app when these tasks exist.                                       
    Task packageAssets = project.tasks.findByPath(":flutter:package${variant.name.capitalize()}Assets")                                  
    Task cleanPackageAssets = project.tasks.findByPath(":flutter:cleanPackage${variant.name.capitalize()}Assets")                        
                                                                                                                                         
    Task copyFlutterAssetsTask = project.tasks.create(name: "copyFlutterAssets${variant.name.capitalize()}", type: Copy) {               
        dependsOn flutterTask                                                                                                            
        if (packageAssets && cleanPackageAssets) {                                                                                       
            dependsOn packageAssets                                                                                                      
            dependsOn cleanPackageAssets                                                                                                 
            into packageAssets.outputDir                                                                                                 
        } else {                                                                                                                         
            dependsOn variant.mergeAssets                                                                                                
            dependsOn "clean${variant.mergeAssets.name.capitalize()}"                                                                    
            into variant.mergeAssets.outputDir                                                                                           
        }                                                                                                                                
        with flutterTask.assets                                                                                                          
    }                                                                                                                                    
                                                                                                                                         
    if (packageAssets) {                                                                                                                 
        String mainModuleName = "app"                                                                                                    
        try {                                                                                                                            
            String tmpModuleName = project.rootProject.ext.mainModuleName                                                                
            if (tmpModuleName != null && !tmpModuleName.empty) {                                                                         
                mainModuleName = tmpModuleName                                                                                           
            }                                                                                                                            
        } catch (Exception e) {                                                                                                          
        }                                                                                                                                
        // Only include configurations that exist in parent project.                                                                     
        Task mergeAssets = project.tasks.findByPath(":${mainModuleName}:merge${variant.name.capitalize()}Assets")                        
        if (mergeAssets) {                                                                                                               
            mergeAssets.dependsOn(copyFlutterAssetsTask)                                                                                 
        }                                                                                                                                
    } else {                                                                                                                             
        variant.outputs[0].processResources.dependsOn(copyFlutterAssetsTask)                                                             
    }                                                                                                                                    
}                                                                                                                                        
複製程式碼

variant 就是上面遍歷的構建變體。首先當構建型別為 debug 時,會在 compileJavaWithJavac 和 compileKotlin 這兩個 task 之前先執行 flutterBuildX86Jar task。它的作用是引入 x86 架構的 jar 和 so 檔案。

這裡有個 bug

project.tasks.findByName('${flutterBuildPrefix}X86Jar')
複製程式碼

判斷是否存在 task 時,拼接字串用的是單引號,正確應該用雙引號,最新版本已經改正了。

接下來,會建立兩個 task,flutterBuild 和 copyFlutterAssets,flutterBuild 用於編譯產物,copyFlutterAssets 則是將產物拷貝到 assets 目錄。因為使用 com.android.applicationcom.android.library 擁有的 task 是不一樣的,所有這裡用是否存在 packageAssets 和 cleanPackageAssets 這兩個 task 去判斷引用不同外掛的模組,同時引入 library 外掛的模組,flutterBuild 需要依賴於這兩個 task。

flutterBuild task 實際上 FlutterTask 型別,同時 FlutterTask 繼承於 BaseFlutterTask。

abstract class BaseFlutterTask extends DefaultTask { 
@OutputFiles                                                                
FileCollection getDependenciesFiles() {                                     
    FileCollection depfiles = project.files()                               
                                                                            
    // Include the kernel compiler depfile, since kernel compile is the     
    // first stage of AOT build in this mode, and it includes all the Dart  
    // sources.                                                             
    depfiles += project.files("${intermediateDir}/kernel_compile.d")        
                                                                            
    // Include Core JIT kernel compiler depfile, since kernel compile is    
    // the first stage of JIT builds in this mode, and it includes all the  
    // Dart sources.                                                        
    depfiles += project.files("${intermediateDir}/snapshot_blob.bin.d")     
    return depfiles                                                         
}                                                                           
}
複製程式碼

@OutputFiles 註解用於標示 task 輸出的目錄,這個可以用來做增量編譯和任務快取等等。

class FlutterTask extends BaseFlutterTask {
  @TaskAction      
void build() {   
    buildBundle()
}                
}

void buildBundle() {                                                                         
    if (!sourceDir.isDirectory()) {                                                          
        throw new GradleException("Invalid Flutter source directory: ${sourceDir}")          
    }                                                                                        
                                                                                             
    intermediateDir.mkdirs()                                                                 
                                                                                             
    if (buildMode == "profile" || buildMode == "release") {                                  
        project.exec {                                                                       
            executable flutterExecutable.absolutePath                                        
            workingDir sourceDir                                                             
            if (localEngine != null) {                                                       
                args "--local-engine", localEngine                                           
                args "--local-engine-src-path", localEngineSrcPath                           
            }                                                                                
            args "build", "aot"                                                              
            args "--suppress-analytics"                                                      
            args "--quiet"                                                                   
            args "--target", targetPath                                                      
            args "--target-platform", "android-arm"                                          
            args "--output-dir", "${intermediateDir}"                                        
            if (trackWidgetCreation) {                                                       
                args "--track-widget-creation"                                               
            }                                                                                
            if (extraFrontEndOptions != null) {                                              
                args "--extra-front-end-options", "${extraFrontEndOptions}"                  
            }                                                                                
            if (extraGenSnapshotOptions != null) {                                           
                args "--extra-gen-snapshot-options", "${extraGenSnapshotOptions}"            
            }                                                                                
            if (buildSharedLibrary) {                                                        
                args "--build-shared-library"                                                
            }                                                                                
            if (targetPlatform != null) {                                                    
                args "--target-platform", "${targetPlatform}"                                
            }                                                                                
            args "--${buildMode}"                                                            
        }                                                                                    
    }                                                                                        
                                                                                             
    project.exec {                                                                           
        executable flutterExecutable.absolutePath                                            
        workingDir sourceDir                                                                 
        if (localEngine != null) {                                                           
            args "--local-engine", localEngine                                               
            args "--local-engine-src-path", localEngineSrcPath                               
        }                                                                                    
        args "build", "bundle"                                                               
        args "--suppress-analytics"                                                          
        args "--target", targetPath                                                          
        if (verbose) {                                                                       
            args "--verbose"                                                                 
        }                                                                                    
        if (fileSystemRoots != null) {                                                       
            for (root in fileSystemRoots) {                                                  
                args "--filesystem-root", root                                               
            }                                                                                
        }                                                                                    
        if (fileSystemScheme != null) {                                                      
            args "--filesystem-scheme", fileSystemScheme                                     
        }                                                                                    
        if (trackWidgetCreation) {                                                           
            args "--track-widget-creation"                                                   
        }                                                                                    
        if (compilationTraceFilePath != null) {                                              
            args "--compilation-trace-file", compilationTraceFilePath                        
        }                                                                                    
        if (createPatch) {                                                                   
            args "--patch"                                                                   
            args "--build-number", project.android.defaultConfig.versionCode                 
            if (buildNumber != null) {                                                       
                assert buildNumber == project.android.defaultConfig.versionCode              
            }                                                                                
        }                                                                                    
        if (baselineDir != null) {                                                           
            args "--baseline-dir", baselineDir                                               
        }                                                                                    
        if (extraFrontEndOptions != null) {                                                  
            args "--extra-front-end-options", "${extraFrontEndOptions}"                      
        }                                                                                    
        if (extraGenSnapshotOptions != null) {                                               
            args "--extra-gen-snapshot-options", "${extraGenSnapshotOptions}"                
        }                                                                                    
        if (targetPlatform != null) {                                                        
            args "--target-platform", "${targetPlatform}"                                    
        }                                                                                    
        if (buildMode == "release" || buildMode == "profile") {                              
            args "--precompiled"                                                             
        } else {                                                                             
            args "--depfile", "${intermediateDir}/snapshot_blob.bin.d"                       
        }                                                                                    
        args "--asset-dir", "${intermediateDir}/flutter_assets"                              
        if (buildMode == "debug") {                                                          
            args "--debug"                                                                   
        }                                                                                    
        if (buildMode == "profile" || buildMode == "dynamicProfile") {                       
            args "--profile"                                                                 
        }                                                                                    
        if (buildMode == "release" || buildMode == "dynamicRelease") {                       
            args "--release"                                                                 
        }                                                                                    
        if (buildMode == "dynamicProfile" || buildMode == "dynamicRelease") {                
            args "--dynamic"                                                                 
        }                                                                                    
    }                                                                                        
}                                                                                            
複製程式碼

@TaskAction 表示的方法就是 task 執行時候的方法。這裡程式碼也很長,其實就是執行了兩個命令。第一,如果是 release 或 profile 模式下,執行 flutter build aot。然後執行 flutter build bundle

實現

分析完 Flutter 接入的流程後,再回頭去看我們一開始面臨的問題,現在我們來解決它。

生成 aar

為了其他成員不需要依賴於 Flutter 環境,首先我們需要將 Flutter 程式碼提前生成為 aar,之所以不是 jar,是因為有圖片資源等。生成產物的命令可以參照 FlutterBuildTask,要注意的是,debug 和 release 模式下生成的產物是不一致的。

debug 模式下的構建產物:

debug

release 模式下的構建產物:

release

Flutter 產物生成不麻煩,照搬命令即可,這主要解決的問題是,Flutter 模組中依賴的第三方外掛,上面我們說到,Flutter 模組依賴的第三方外掛會生成到配置檔案 .flutter-plugins 中。然後在 settings.gradle 中,將這些專案的原始碼加入我們專案的依賴中去。所有,我們要提前構建的話,就需要將這些程式碼也打進我們的 aar 中。可惜,官方不支援這種操作,這時候需要第三方庫來支援了,fataar-gradle-plugin,不過這個庫有個小坑,Android Gradle 外掛 3.1.x 的時候,沒有將 jni 目錄的 so 輸出到 aar 中,解決方式,新增:

project.copy {
                from "${project.projectDir.path}/build/intermediates/library_and_local_jars_jni/${variantName}"
                include "**"
                into "${temporaryDir.path}/${variantName}/jni"
            }
複製程式碼

經過這兩個步驟後,我們就能提前將 Flutter 產物和第三方外掛的 aar 都打包一個 aar,上傳 maven 上等等。

原始碼管理

因為 Android 專案和 iOS 專案都需要用到同一套 Flutter 原始碼,所以這裡我們可以使用 git 提供的 submodule 的形式接入原始碼。關於 Flutter SDK 版本管理,可以參照之前的文章:flutterw

結尾

因為篇幅原因,所以不能將實現細節完整寫出來,只能將一些關鍵點整理出來,希望能對大家有點啟發。有其他疑問,歡迎留言討論。

相關文章