腾讯课堂Flutter工程实践系列——接入篇

前言

课堂目前的技术栈是 React Native + Hybird + Native ,随着技术的演进多端融合的趋势越来越明显,而RN的弊端也突显出来,jsBridge性能不是最优,占用前端人力,定位问题链路较长等问题,让我们重新思考有没有更好的跨平台方案来解决业务场景,这个时候Flutter出现了,课堂iPad版本已经完成Flutter化并且稳定上线,让我们对这门新的跨平台技术有了信心。本文介绍课堂App如何实现在原生工程中嵌入Flutter,完成初步的框架搭建。

接入方案对比

在接入之前我们做了目前主流的两种接入方案进行了对比,分别是:

  • Standalone管理模式

  • Add To App管理模式

Standalone管理模式

Flutter Application标准工程,lib存放Flutter的代码,android是标准的Android工程、iOS是标准的的Xcode工程。

在我们团队实践后发现,统一管理模式有以下明显的缺点:

  • 比较适合 Flutter为主,Native为辅 的场景

  • 原生工程迁移成本高,需要另开独立工程维护

  • 工程臃肿,代码依赖耦合度高,无法独立编译构建

  • 混编模式下,相关工具链耗时大幅增加,导致开发效率降低

那既然Standalone管理模式不适合存量项目,那有没有一种能够直接嵌入到原生工程,把Flutter当做一个module来集成,答案是有的。

Add to App管理模式

创建一个Flutter Module工程如下图:

那么原生工程怎么接入,我们分别看下Android和iOS:

Android的接入

settings.gradle中进行以下配置:

app/build.gradle 引入Flutter Module:

iOS的接入

Podfile引入Flutter:

课堂最终选择了这种集成方式,因为它具有以下优点:

  • 三端分离,最小化程度影响原生工程

  • 可以更方便的实现源码依赖和产物依赖

  • 直接进行原生和Dart代码开发调试

因为绝大部分存量App不会重新开发一个Flutter App,因为成本太高,所以如果你的App并不是新项目笔者是比较推荐采用Add-To-App这种管理模式,可以逐步切入Flutter开发。

踩坑经验

这部分主要讲下Android接入Flutter过程中遇到一些典型的问题:

异常1:Gradle DSL method not found: 'google()'

课堂用的gradle版本还是比较旧的,需要升级一下:

升级完gradle,也要升级插件:

异常2:AAPT error:resource android:attr/fontVariationSettings not found

这个异常需要将compileSdkVersion升级到28,之前是26。

异常3:assert appProject !=null

这个问题是Flutter SDK中的一个bug,我们主工程名并并不是 app ,而是 course ,所以在编译的时候会找不到project的问题,这里有两种解法:

  1. 重命名module名字,命名为app

  2. 修改flutter脚本(课堂选的是这种)

通过上图的修改,flutter脚本也能找到我们的工程,编译也ok了。

但编译ok不代表能真正run起来,以module接入的我们遇到的一个巨坑就是找不到 libflutter.so libapp.so 的问题。如下图所示:

出现这个问题的原因是我们项目为了减少包大小,只用了armeabi一个so架构,这也是很多旧项目采用的cpu架构。而 Flutter SDK最低支持到armeabi-v7a架构 ,如果不做特殊处理,就会出现上面的Crash。

一般我们的做法就是直接将armeabi-v7a的so复制到armeabi下,所以我们只需要在找到合适的时机将Flutter的构建产物copy到原生的armeabi架构下即可解决问题。 在混编Flutter的流程中,通过hook gradle task的方式插入自定义task来实现libflutter.so的复制

app/copyFlutterSo.sh


 

#!/bin/bash


# 当前目录

CURRENT_DIR="`pwd`"

# 当前build目录,具体以工程为准

BUILD_DIR="`pwd`/build"

# gradle 5.6.2 armeabi so路径

#ARMEABI_DIR="$BUILD_DIR/intermediates/merged_native_libs/debug/out/lib/armeabi"

# gradle 4.10.1 armeabi so路径

ARMEABI_DIR="$BUILD_DIR/intermediates/transforms/mergeJniLibs/$1/0/lib/armeabi"

# armeabi-v7a so存放路径

ARMEABI_V7A_DIR="$BUILD_DIR/intermediates/transforms/mergeJniLibs/$1/0/lib/armeabi-v7a"


echo -e "\033[47;30m ========== copy $1 libflutter.so ========== \033[0m"

if [[ "$1" == "debug" ]]; then

# 将libflutter.so copy到armeabi架构中去

cp -rf ${ARMEABI_V7A_DIR}/libflutter.so ${ARMEABI_DIR}

echo "copy ${ARMEABI_V7A_DIR}/libflutter.so to ${ARMEABI_DIR}"


elif [[ "$1" == "profile" ]]; then

# 将libflutter.so copy到armeabi架构中去

cp -rf ${ARMEABI_V7A_DIR}/libflutter.so ${ARMEABI_DIR}

# 将libapp.so也copy到armeabi架构中去

cp -rf ${ARMEABI_V7A_DIR}/libapp.so ${ARMEABI_DIR}

echo "copy ${ARMEABI_V7A_DIR}/libflutter.so to ${ARMEABI_DIR}"

echo "copy ${ARMEABI_V7A_DIR}/libapp.so to ${ARMEABI_DIR}"



elif [[ "$1" == "release" ]]; then

# 将libflutter.so copy到armeabi架构中去

cp -rf ${ARMEABI_V7A_DIR}/libflutter.so ${ARMEABI_DIR}

# 将libapp.so也copy到armeabi架构中去

cp -rf ${ARMEABI_V7A_DIR}/libapp.so ${ARMEABI_DIR}

echo "copy ${ARMEABI_V7A_DIR}/libflutter.so to ${ARMEABI_DIR}"

echo "copy ${ARMEABI_V7A_DIR}/libapp.so to ${ARMEABI_DIR}"

fi

以上脚本的作 用就是找到armeabi-v7a的so直接copy一份到armeabi目录下,可以看到使用不同的Gradle版本,so的路径会有一些差异,这个也是分析Gradle Task的执行结果的时候发现的。

app/build.gradle


 

afterEvaluate { project ->

android.applicationVariants.each { variant ->


/**

* 由于flutter不支持armeabi,此处在merge(Debug|Profile|Release)NativeLibs与strip(Debug|Profile|Release)DebugSymbols之间插入一个任务,

* 将libflutter.so和libapp.so拷贝到merged_native_libs/(debug|profile/release)/out/lib/armeabi目录下,使它们能打到最终的apk里。

*

* 详情见copyFlutterSo.sh

*/

def taskPostfix = variant.name.substring(0, 1).toUpperCase() +

variant.name.substring(1)

project.task("copyFlutterSo$taskPostfix") {

doLast {

exec {

// 执行shell脚本

commandLine "sh", "./copyFlutterSo.sh", variant.name

}

}

}

// 注意这个是在gradle 5.6.2版本的task

// project.tasks["copyFlutterSo$taskPostfix"].dependsOn(project.tasks["merge$taskPostfix" + "NativeLibs"])

// project.tasks["strip$taskPostfix" + "DebugSymbols"].dependsOn(project.tasks["copyFlutterSo$taskPostfix"])

//

// gradle 4.10.1,注意插入task的依赖顺序

project.tasks["copyFlutterSo${taskPostfix}"].dependsOn(project.tasks["transformNativeLibsWithMergeJniLibsFor${taskPostfix}"])

project.tasks["process${taskPostfix}JavaRes"].dependsOn(project.tasks["copyFlutterSo$taskPostfix"])

}

}

熟悉gradle编译的同学应该能看懂以上脚本,需要注意下不同的gradle版本除了构建产物的位置不同,连需要hook的task也有所不同。

通过上图可以看到我们已经把debug版本的apk已经把 libflutter.so 从armeabi-v7a目录下复制到了armeabi目录下。

解决了so的问题,基本上我们能够正常的把项目编译运行起来了。下面我们尝试将我们的首页替换成Flutter的页面。

原生页面引入Flutter页面

先说明一点,写这篇文章的时候我们用的Flutter版本是: v1.12.13+hotfix5 ,跟以前的版本使用会有些差异,具体可以参考官方的wiki。

我们首先做的尝试是将首页替换成Flutter页面,做了以下调整:

CategoryFragment .java


 

Override

public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {

View view = inflater.inflate(R.layout.fragment_index, container, false);


FlutterEngine flutterEngine = new FlutterEngine(getActivity());

flutterEngine.getDartExecutor().executeDartEntrypoint(

DartExecutor.DartEntrypoint.createDefault()

);

flutterEngine.getNavigationChannel().setInitialRoute("category");



FlutterView flutterView = new FlutterView(getActivity());

FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);

FrameLayout flContainer = view.findViewById(R.id.fl_content);

// 关键代码,将Flutter页面显示到FlutterView

flutterView.attachToFlutterEngine(flutterEngine);

flContainer.addView(flutterView, lp);

return view;

}

fragment_index.xml


 

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"

android:orientation="vertical" android:layout_width="match_parent"

android:layout_height="match_parent">


<!-- 嵌入flutter视图 -->

<FrameLayout

android:id="@+id/fl_content"

android:layout_width="match_parent"

android:layout_height="match_parent"

/>


</LinearLayout>

Dart代码实现:

main.dart


 

import 'package:edu/category_page_app.dart';

import 'package:flutter/material.dart';

import 'dart:ui';

import 'dart:convert';


void main() {

runApp(_widgetForRoute(window.defaultRouteName));

}

// 获取路由名称

String _getRouteName(String s) {

if (s.indexOf('?') == -1) {

return s;

} else {

return s.substring(0, s.indexOf('?'));

}

}


// 获取参数

Map<String, dynamic> _getParamsStr(String s) {

if (s.indexOf('?') == -1) {

return Map();

} else {

return json.decode(s.substring(s.indexOf('?') + 1));

}

}


Widget _widgetForRoute(String url) {

String route = _getRouteName(url);

Map<String, dynamic> params = _getParamsStr(url);

switch (route) {

default:

return MaterialApp(

theme: ThemeData(

primaryColor: Color(0xFF008577),

primaryColorDark: Color(0xFF00574B),

),

home: CategoryPageApp(route, params),

);

}

}

category_page_app.dart


 

import 'package:flutter/material.dart';


class CategoryPageApp extends StatefulWidget {

String route;

Map<String, dynamic> params;


CategoryPageApp(this.route, this.params);


@override

State<StatefulWidget> createState() {

return _CategoryPageState();

}

}


class _CategoryPageState extends State<CategoryPageApp> {

@override

Widget build(BuildContext context) {

return Scaffold(

appBar: AppBar(

title: Text('Flutter页面'),

automaticallyImplyLeading: false,

),

body: Center(

child: Text('分类页'),

),

);

}

}

以上代码是我们尝试在原生 Fragment嵌入FlutterView 的方式来展示Flutter页面。详细的代码实现这里就不方便贴了,我们目前分类页Flutter迁移效果如下图:

可以看到分类页已经成功替换成Flutter页面了,基本跟React Native无异,目前课堂分类页版本正在灰度当中,相信很快大家就能够体验到我们迁移Flutter后的效果了。

总结

笔者花了很大的篇幅讲了课堂接入Flutter的过程,可以发现并不是一帆风顺的,遇到很多坑坑洼洼,但总的来说以module的形式接入算是完成了。本篇作为Flutter系列的第一篇,后续会有更多的工程实践在路上,帮助其他产品在接入过程中少走一些弯路和知识沉淀是我们的初衷,毕竟腾讯课堂就是一款传播知识的产品,也希望借助Flutter作为新的跨平台框架给产品带来更多赋能,为统一技术栈和多端融合打下坚实的基础。

我来评几句
登录后评论

已发表评论数()

相关站点

热门文章