Flutter是Google推出的可以高效构建Android、iOS界面的移动UI框架,在国内中大公司像闲鱼/Now直播等app陆续出现它的影子,当然闲鱼的最为成熟,闲鱼也非常的高效产出了很多优秀的文章。

本文是基于Flutter SDK : 0.7.3
在最新的SDK v0.11.13中或者说运行后发现没有PathProviderPlugin / SharedPreferencesPlugin 对应的目录以及jar包,那是因为新版本中已经不需要了 自然就可以删除。

可是

可是,网上能找到的混合开发方案或者动态更新flutter的相关文章都没法符合我自己理想的效果。所以自己摸索了一套混合开发和动态更新的方案,这里记录一下摸索过程。

Flutter源码分析

如果说把自家的app改造成纯Flutter方案那是不可能的,顶多是某个模块或者某些模块改成Flutter,所以自然想到Flutter如何跟原生混合开发,混合开发不是说java去调用dart中的方法更多的是指如何从当前Activity跳转到Flutter实现的界面,要像知道这些东西那么必须得弄懂Flutter源码,不求深入但求知之一二三四。
Android的应用那么自然先找Application,所以很快找到了FlutterApplication:

public class FlutterApplication extends Application {
    private Activity mCurrentActivity = null;

    public FlutterApplication() {
    }

    @CallSuper
    public void onCreate() {
        super.onCreate();
        FlutterMain.startInitialization(this);
    }

    public Activity getCurrentActivity() {
        return this.mCurrentActivity;
    }

    public void setCurrentActivity(Activity mCurrentActivity) {
        this.mCurrentActivity = mCurrentActivity;
    }
}

还行初始化的东西不多,直接进入onCreate对应的FlutterMain.startInitialization中去看看:

public static void startInitialization(Context applicationContext, FlutterMain.Settings settings) {
      long initStartTimestampMillis = SystemClock.uptimeMillis();
      initConfig(applicationContext);
      initAot(applicationContext);
      initResources(applicationContext);
      System.loadLibrary("flutter");
      long initTimeMillis = SystemClock.uptimeMillis() - initStartTimestampMillis;
      nativeRecordStartTimestamp(initTimeMillis);
 }

不具体一行一行的看代码,但是看到了几个很关键的词在initConfig方法中:

private static void initConfig(Context applicationContext) {
	  Bundle metadata = applicationContext.getPackageManager().getApplicationInfo(applicationContext.getPackageName(), 128).metaData;
	  if (metadata != null) {
	      sAotSharedLibraryPath = metadata.getString(PUBLIC_AOT_AOT_SHARED_LIBRARY_PATH, "app.so");
	      sAotVmSnapshotData = metadata.getString(PUBLIC_AOT_VM_SNAPSHOT_DATA_KEY, "vm_snapshot_data");
	      sAotVmSnapshotInstr = metadata.getString(PUBLIC_AOT_VM_SNAPSHOT_INSTR_KEY, "vm_snapshot_instr");
	      sAotIsolateSnapshotData = metadata.getString(PUBLIC_AOT_ISOLATE_SNAPSHOT_DATA_KEY, "isolate_snapshot_data");
	      sAotIsolateSnapshotInstr = metadata.getString(PUBLIC_AOT_ISOLATE_SNAPSHOT_INSTR_KEY, "isolate_snapshot_instr");
	      sFlx = metadata.getString(PUBLIC_FLX_KEY, "app.flx");
	      sSnapshotBlob = metadata.getString(PUBLIC_SNAPSHOT_BLOB_KEY, "snapshot_blob.bin");
	      sFlutterAssetsDir = metadata.getString(PUBLIC_FLUTTER_ASSETS_DIR_KEY, "flutter_assets");
	  }
    }

没错就是vm_snapshot_data、vm_snapshot_instr、isolate_snapshot_data、isolate_snapshot_instr为什么说这几个这么重要呢?

android 原生混合flutter flutter和android混合_android 原生混合flutter


看下上面这几个编译的产物,我们就知道这就Flutter的核心东西。或者换句话说只要弄懂了这个玩意很有可能我们就悟出混合开发的方案了,那么他们是怎么读取assets目录下的这些玩意呢?

private static void initAot(Context applicationContext) {
        Set<String> assets = listAssets(applicationContext, "");
        sIsPrecompiledAsBlobs = assets.containsAll(Arrays.asList(sAotVmSnapshotData, sAotVmSnapshotInstr, sAotIsolateSnapshotData, sAotIsolateSnapshotInstr));
        sIsPrecompiledAsSharedLibrary = assets.contains(sAotSharedLibraryPath);
        if (sIsPrecompiledAsBlobs && sIsPrecompiledAsSharedLibrary) {
            throw new RuntimeException("Found precompiled app as shared library and as Dart VM snapshots.");
        }
    }

看到方法跟Assets挂钩确实很惊喜,因为看到肯定是从Assets中把这些读出来的。可是读出来放哪里去?
那最后的那个方法initResources该方法就是涉及存放的位置,跟着源码一路看下去,在ExtractTask.extractResources找到了一点猫腻:

File dataDir = new File(PathUtils.getDataDirectory(ResourceExtractor.this.mContext));

确实,就是在data/data/xxx/flutter_assets/路径下:

android 原生混合flutter flutter和android混合_flutter混合开发_02


大体知道了这些个产物之后,界面是怎么加载? 首先加载Flutter的界面是个Activity叫FlutterActivity主要是通过FlutterActivityDelegate这个类,然后我们主要看FlutterActivity.onCreate => FlutterActivityDelegate.onCreate这个流程:

public void onCreate(Bundle savedInstanceState) {
	// 沉浸式模式
        if (VERSION.SDK_INT >= 21) {
            Window window = this.activity.getWindow();
            window.addFlags(-2147483648);
            window.setStatusBarColor(1073741824);
            window.getDecorView().setSystemUiVisibility(1280);
        }

        String[] args = getArgsFromIntent(this.activity.getIntent());
        FlutterMain.ensureInitializationComplete(this.activity.getApplicationContext(), args);
        this.flutterView = this.viewFactory.createFlutterView(this.activity);
        if (this.flutterView == null) {
            FlutterNativeView nativeView = this.viewFactory.createFlutterNativeView();
            this.flutterView = new FlutterView(this.activity, (AttributeSet)null, nativeView);
            this.flutterView.setLayoutParams(matchParent);
            this.activity.setContentView(this.flutterView);
            this.launchView = this.createLaunchView();
            if (this.launchView != null) {
                this.addLaunchView();
            }
        }
    }

所以界面最重要的方法就是ensureInitializationComplete也就是把flutter相关的初始化进来然后使用FlutterView进行加载显示:

ensureInitializationComplete:// 进行初始化
String appBundlePath = findAppBundlePath(applicationContext);
String appStoragePath = PathUtils.getFilesDir(applicationContext);
nativeInit(applicationContext, (String[])shellArgs.toArray(new String[0]), appBundlePath, appStoragePath);

// 找到data/data/xxx/flutter_assets下的flutter产物
public static String findAppBundlePath(Context applicationContext) {
    String dataDirectory = PathUtils.getDataDirectory(applicationContext);
    File appBundle = new File(dataDirectory, sFlutterAssetsDir);
    return appBundle.exists() ? appBundle.getPath() : null;
}

然后每一个FlutterView中包了一个FlutterNativeView然后最终就是FlutterView->runFromBundle调用FlutterNativeView->runFromBundle最后渲染到界面上。

到此我们大概了解了Flutter需要的产物vm_snapshot_data、vm_snapshot_instr、isolate_snapshot_data、isolate_snapshot_instr然后简单的了解了加载流程,最后附上大闲鱼的一张编译大图:

android 原生混合flutter flutter和android混合_flutter源码分析_03

混合开发

所以我觉得Flutter应该跟ReactNative类似只要把相关的bundle文件放入我们app的assets即可,所以拿这个方向开始编译Flutter代码,开开心心的输入flutter run之后在AS中怎么就是找不到相关产物,作为Android开发者知道肯定会有个build目录怎么就是不显示。所以去电脑对应的盘中看了下是有这么个build目录但是AS不显示,这样子办事很慢所以这里需要先加一个gradle task

task flutterPlugin << {
    println "工程目录 = ${project.rootDir}/"
    println "编译成功的位置 = ${this.buildDir}/"

    def projectName = this.buildDir.getPath()
    projectName = projectName.substring(0, projectName.length() - "app/".length())

    def rDir = new File("${this.rootDir}/FlutterPlugin/")
    def bDir = new File(projectName)

    if (!rDir.exists()) {
        rDir.mkdirs()
    } else {
        rDir.deleteDir()
    }

    bDir.eachDir {File dir ->
        def subDir = dir.getPath()
        def flutterJarDirName = subDir.replace("${projectName}/", "")
        def flutterJarDir = null
        if (subDir.contains("app")) {// 如果是app目录的话 拷贝编译后生成的flutter目录
            flutterJarDir = new File("${subDir}/intermediates/assets/")
        } else {
            flutterJarDir = new File("${subDir}/intermediates/intermediate-jars/")
        }
        project.copy {
            from flutterJarDir
            into "${rDir}/${flutterJarDirName}"
        }
    }
}

把看不到的build中产物给拷贝出来,将结果放入工程的FlutterPlugin目录下:

android 原生混合flutter flutter和android混合_flutter动态更新_04


红色框内的东西是Flutter的gradle插件产生的依赖包,我们也是需要的,所以顺便一起拷贝出来,那需要在哪?看下面的这个类就知道了。

public final class GeneratedPluginRegistrant {
  public static void registerWith(PluginRegistry registry) {
 	PathProviderPlugin.registerWith(registry.registrarFor("io.flutter.plugins.pathprovider.PathProviderPlugin"));
    SharedPreferencesPlugin.registerWith(registry.registrarFor("io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin"));
  }
}

到此为止我们把编译flutter的产物都拷贝出来,所以我们直接将这些产物放入我们的远程工程对应的assets以及lib路径中去。可是对应的FlutterActivity还是报红,所以说flutter还有一些产物没有被我们发现。这时也不知道是什么玩意,所以就找大闲鱼的文章<贴在末尾>,最终找到了还有一个flutter.jar包没有引入。

android 原生混合flutter flutter和android混合_flutter动态更新_05


这就是最终在原生的工程下新建了一个fluttermodule模块的最终层级关系了。然后把demo中的类相关拿进来通过startActivity成功的进入到FlutterActivity。

这里还是要把大闲鱼说的相关产物解释附上:

android 原生混合flutter flutter和android混合_flutter_06


混合开发的巨坑:

很开心的运行然后用AS打开一看对应的flutter.so确是armv8a的框架,如果说直接拿到我们app中去就挂了因为我们app中:

ndk {
	 abiFilters "armeabi-v7a"
}

因为我们只用v7a的框架,这就很头痛了。

apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"

我们的新建flutter项目有这么一个gradle文件,所以说so兼容问题肯定是这货引起的。所以跟着进去看看哪里有猫腻…
还算比较顺利很快找到原因 原来这个gradle插件会自动的帮你找到最适合当前环境的so文件,所以我们只需要强制让它返回v7a的即可:

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'
            // }
            // targetArch = 'arm'

也就是说让targetArch为arm即可,所以说flutter混合进来的时候最大的坑就是我觉得就是so兼容问题,索性还是比较顺利。

Flutter动态更新方案

当我完成混合成功之后,我就在想能不能像其他的混合开发库能实现动态更新。这里再次感谢大闲鱼的思路:因为大闲鱼说直接把data/data/xxxxx下的vm_snapshot_data、vm_snapshot_instr、isolate_snapshot_data、isolate_snapshot_instr替换成新编译成功的那么界面加载出来的就是新的界面,所以说这不就是动态更新吗?
所以说跟着节奏试试,将编译出来的打包成zip放入sd卡中去…
第一步:

/**
  * 解压SD路径下的flutter包
  */
 public static void doUnzipFlutterAssets() throws Exception {
     String sdCardPath = Environment.getExternalStorageDirectory().getPath() + File.separator;
     String zipPath = sdCardPath + "flutter_assets.zip";
     File zipFile = new File(zipPath);
     if (zipFile.exists()) {
         ZipFile zFile = new ZipFile(zipFile);
         Enumeration zList = zFile.entries();
         ZipEntry zipEntry;
         byte[] buffer = new byte[1024];

         while (zList.hasMoreElements()) {
             zipEntry = (ZipEntry) zList.nextElement();
             Log.w("Jacyuhou", "==== zipEntry Name = " + zipEntry.getName());
             if (zipEntry.isDirectory()) {
                 String destPath = sdCardPath + zipEntry.getName();
                 Log.w("Jayuchou", "==== destPath = " + destPath);
                 File dir = new File(destPath);
                 dir.mkdirs();
                 continue;
             }

             OutputStream out = new BufferedOutputStream(new FileOutputStream(new File(sdCardPath + zipEntry.getName())));
             InputStream is = new BufferedInputStream(zFile.getInputStream(zipEntry));

             int len;
             while ((len = is.read(buffer)) != -1) {
                 out.write(buffer, 0, len);
             }
             out.flush();
             out.close();
             is.close();
         }
         zFile.close();
     }
 }

第二步:

/**
     * 拷贝到data/data路径下
     */
    public static void doCopyToDataFlutterAssets(Context mContext) throws Exception {
        String destPath = PathUtils.getDataDirectory(mContext.getApplicationContext()) + File.separator + "flutter_assets/";
        String originalPath = Environment.getExternalStorageDirectory().getPath() + File.separator + "flutter_assets/";
        Log.w("Jayuchou", "===== dataPath = " + destPath);
        Log.w("Jayuchou", "===== originalPath = " + originalPath);
        File destFile = new File(destPath);
        File originalFile = new File(originalPath);

        File[] files = originalFile.listFiles();
        for (File file : files) {
            Log.w("Jayuchou", "===== file = " + file.getPath());
            Log.w("Jayuchou", "===== file = " + file.getName());
            if (file.getPath().contains("isolate_snapshot_data")
                    || file.getPath().contains("isolate_snapshot_instr")
                    || file.getPath().contains("vm_snapshot_data")
                    || file.getPath().contains("vm_snapshot_instr")) {
                doCopyToDestByFile(file.getName(), originalFile, destFile);
            }
        }
    }

将对应的文件拷贝到data目录下去,跑起来看看 总算是成功了…

android 原生混合flutter flutter和android混合_flutter_07


看上面的gif图,一开的Flutter界面上显示null 那么你完了线上的包显示null错误,所以这时就需要紧急发个补丁包,然后经过Http下载下来重新打开界面就修复了这个错误。

所以说这就是动态更新的方案…

END…