到家团队结合各种业务的形态,使其各个业务线具备独立开发和调试的能力,设计出通用的工程方案结构图
目前大型APP都是通过原生+Flutter的混合开发模式,因此整合后分层的到家主APP架构设计图如下:
class ResGenerator extends GeneratorForAnnotation<ResPath> {
@override
generateForAnnotatedElement(
Element element, ConstantReader annotation, BuildStep buildStep) {
//1.读取pubspec.yaml文件的配置
var package = loadYaml(new File('pubspec.yaml')?.readAsStringSync());
//2.默认扫描pubspec.yaml里asset下的目录
var defaultPath = pubspecModel?.flutter?.assets;
//3.目录下文件进行扫描并标记
for (String pathStr in pathList) {
handleFile(pathStr);
}
//4.单独对字体等资源进行处理
List<Fonts> fonts = pubspecModel?.flutter?.fonts;
//5.根据标记分块生成dart文件
return //模版代码
}
}
在执行flutter packages pub run build_runner build命令后生成R.dart文件
class R {
//包名
static const package = _Package();
//图片资源类
static const image = _Image();
//其他资源类
}
class _Package {
const _Package();
final String plugin_a = 'plugin_a';
}
class _Image {
const _Image();
final String image1 = 'asset/image/image1.png';
}
由于需要拿到依赖Plugin的页面注解 所以build.yaml中配置需要注意:
route_builder:
import: 'package:jddj_builder.dart'
builder_factories: ['routeBuilder']
build_extensions: { '.dart': ['.route.dart'] }
auto_apply: all_packages//Plugin被main依赖 all_packages才能拿到Plugin页面注解
build_to: cache
使用时在页面上添加注解类
@DJRoute("pageA",alias: [DJRouteAlias("pageA1")],其他配置信息)
class PageA extends StatefulWidget {
}
生成页面配置信息如下
Map<String, PageBuilder> registerPageBuilders() {
final Map<String, PageBuilder> builders = <String, PageBuilder>{
'pageA': (pageName, params, _) => PageA(pageName, params: params),
'pageB': (pageName, params, _) => PageB(pageName, params: params),
'pageC': (pageName, params, _) => PageC(pageName, params: params),
};
return builders;
}
每个Flutter业务工程自动资源管理和路由解耦设计如下:
传统更新方式:
1、AppPage监听门店item点击,调用自身setState()
弊端:调用AppPage的setState()其子Widget都会刷新,不只是分类列表和商品列表会刷新; 代码实现如下:
@overrideWidget build(BuildContext context)
{
return Scaffold(
appBar: AppBar(
title:Text("标题栏"),
body:ScrollView(childeren:[
StoreListWidget(onItemClick(){
//...业务逻辑处理
setState(){};
});
CateListWidget();
GoodListWidget();
])));}
2、创建GlobalKey,传递给分类列表和商品列表,门店item点击通过GlobalKey获取State对象调用stateState()进行刷新
弊端:GlobalKey使用静态常量Map保存对应的Element,过多使用GlobalKey对内存开销很大 代码实现如下:
GlobalKey cateKey=GlobalKey();
GlobalKey goodKey=GlobalKey();
@overrideWidget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title:Text("标题栏"),
body:ScrollView(childeren:[
StoreListWidget(onItemClick(){
//...业务逻辑处理
if(cateKey!=null&&cateKey.currentState!=null){
cateKey.currentState.setState();
}
if(goodKey!=null&&goodKey.currentState!=null){
goodKey.currentState.setState();
}
});
CateListWidget(key:cateKey);
GoodListWidget(key:goodKey);
])));}
3、如何进行状态管理?
首先创建View层(只负责构建布局,不参与任何逻辑);代码实现如下:
@override Widget build(BuildContext context)
{
return DJNotifyWidget<T Provide>(builder: ( context, value)
{
return child;
}
}
构建View层也可以使用DJSelectorNotifyWidget,刷新时只有对应的selector中的data发生改变才会重新绘制DJSelectorNotifyWidget中的child 代码实现如下:
DJSelectorNotifyWidget<List<Model>,T Provider>
(selector: (context, data)
{ return data;}, builder: (context, value)
{ return child);});
然后创建provider层(只负责界面数据的获取和逻辑运算),代码实现如下:
class APPPageProvide extends BaseProvide {
///专门处理网络请求 final APPPageRepo appPageRepo;
///门店点击
void clickStore() {
appPageRepo.requestCategoryAndGoods(onSuccess: (result) {
//...业务逻辑处理
if (couponProviders[kCateListProvide] != null) {
couponProviders[kCateListProvide].notifyListeners();
}
if (couponProviders[kSkuListProvide] != null) {
couponProviders[kSkuListProvide].notifyListeners();
}
}, onError: (msg, {code})
{
//...业务逻辑处理
if (couponProviders[kCateListProvide] != null) {
couponProviders[kCateListProvide].notifyListeners();
}
if (couponProviders[kSkuListProvide] != null) {
couponProviders[kSkuListProvide].notifyListeners();
} });}
最后使用DJBaseProvideNode将View和Provider结合起来使用,到此就完成了整个布局过程,代码实现如下:
@overrideWidget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title:Text("标题栏"),
body:DJBaseProvideNode(
baseProvider,
childeren:[
StoreListWidget(onItemClick(){
baseProvider.clickStore();
});
DJBaseProvideNode<CateProvider>(cateProvider,CateListWidget),
DJBaseProvideNode<GoodProvider>(goodProvider,GoodListWidget
])));}
1、精准曝光的基本规则
1)模块第一次出现在可视区域触发曝光上报
2)刷新接口数据后,在可视区域的模块触发曝光上报
3)滑出可视区域的模块,再次滑入可视区域时需要再次触发曝光上报
2、精准曝光的计算:
(1)模块第一次出现在可视区域,可以在Widget状态的initState()方法中计算
(2)刷新接口数据后,可以在Widget状态的didUpdateWidget()方法中计算
(3)滑出可视区域的模块,再次滑入可视区域时,可以应用NotificationListener监听滚动事件实时计算
//获取渲染Render类
RenderSliver renderSliver = context.ancestorRenderObjectOfType(TypeMatcher<RenderSliver>());
//计算可见高度和总高度
totalHeight = renderSliver.geometry.scrollExtent;
visibleHeight = renderSliver.geometry.paintExtent;
第二种(Sliver模块的计算):
//获取渲染Render类
RenderSliverMultiBoxAdaptor renderSliverMultiBoxAdaptor = context.ancestorRenderObjectOfType(TypeMatcher<RenderSliverMultiBoxAdaptor>());
//Sliver的起始绘制位置
double startOffset = renderSliverMultiBoxAdaptor.constraints.scrollOffset;
//Sliver绘制高度
double paintExtent = renderSliverMultiBoxAdaptor.geometry.paintExtent;
// Sliver的结束绘制位置
double endOffset = startOffset + paintExtent;
// 主轴方向
Axis axis = renderSliverMultiBoxAdaptor.constraints.axis;
// 当前item相对于列表起始位置的偏移
double itemLayoutOffset = 0;
Size itemSize;
double itemStartOffset = 0;
double itemEndOffset = 0;
double itemStartOffsetClamp = 0;
double itemEndOffsetClamp = 0;
context.visitAncestorElements((element) {
if (element.renderObject == null) {
return true;
}
if (element.renderObject.parentData == null) {
return true;
}
if (!(element.renderObject.parentData is SliverLogicalParentData)) {
return true;
}
//item的起始绘制位置
itemLayoutOffset = (element.renderObject.parentData as SliverLogicalParentData).layoutOffset;
//item尺寸
itemSize = (element.renderObject as RenderBox).size;//item的起始绘制位置
itemStartOffset = itemLayoutOffset;//item的结束绘制位置
itemEndOffset = axis == Axis.vertical ? itemStartOffset + itemSize.height :itemStartOffset + itemSize.width;
//可见区域开始位置
itemStartOffsetClamp = itemStartOffset.clamp(startOffset+topOverlapCompensation, endOffset- bottomOverlapCompensation);
//可见区域结束位置
itemEndOffsetClamp = itemEndOffset.clamp(startOffset+topOverlapCompensation, endOffset- bottomOverlapCompensation);
//计算可见高度和总高度
double visibleHeight = itemEndOffsetClamp - itemStartOffsetClamp;
double totalHeight = itemEndOffset - itemStartOffset;
return false;
});
3、特殊情况的处理:
(1)普通列表曝光:
可以通过获取viewportTop, viewportBottom, itemTop, itemBottom四个值,轻松计算出可视高度。
(2)嵌套列表曝光:
Item需要计算横向和纵向两个方向滚动的曝光。
横向曝光:监听内层横向滚动列表的滚动事件,实时计算每个item的可见宽度,进行曝光。
竖向曝光:监听外层竖向滚动列表滚动事件,实时计算floor2的竖向可见高度。当floor2满足曝光条件,通过发送消息,通知每个item计算自己在横向的可见宽度,从而进行曝光。
(3)多行嵌套列表曝光
向每个item传入可视区域顶部viewportTop和可视区域底部viewportBottom值。在2的基础上判断每个item是否在竖向可视范围内,在可视范围进行曝光,否则不曝光。
请注意以下的实现原理和方案都是基于Flutter sdk v1.9.1及以上版本。首先我们弄清楚Flutter的编译产物是什么
Flutter编译产物
我们创建一个Flutter工程,在终端执行flutter build apk命令编译构建,在Flutter工程build目录下就成功生成了apk文件,然后解压apk文件
从上图得知Flutter 编译产物分为以下几类:
1、libflutter.so 是Flutter引擎动态库文件
2、libapp.so 是dart代码编译产生的可执行文件
3、assets/flutter_assets 主要存放了Flutter的资源文件,包括图片、字体等文件
libflutter.so为Flutter引擎文件,初次打包apk内置即可,不参与动态更新,因此我们只需要替换libapp.so以及flutter_assets下所有文件,就能实现Flutter代码和资源的动态更新,在弄清楚需要替换的Flutter编译产物之后,接下来对动态加载的可行性做一个简单的验证。
// Most devices can load the AOT shared library based on the library name
// with no directory path. Provide a fully qualified path to the library
// as a workaround for devices where that fails.
shellArgs.add("--" + AOT_SHARED_LIBRARY_NAME + "=" + applicationInfo.nativeLibraryDir + File.separator + sAotSharedLibraryName);
}
shellArgs.add("--cache-dir-path=" + PathUtils.getCacheDirectory(applicationContext));
if (sSettings.getLogTag() != null) {
shellArgs.add("--log-tag=" + sSettings.getLogTag());
}
String appStoragePath = PathUtils.getFilesDir(applicationContext);
String engineCachesPath = PathUtils.getCacheDirectory(applicationContext);
FlutterJNI.nativeInit(applicationContext, shellArgs.toArray(new String[0]),
kernelPath, appStoragePath, engineCachesPath);
从上图可以看出,此方法主要初始化一些参数配置信息,包括Flutter打包出的appBundle路径、应用存储目录、引擎缓存目录,特别注意到的是还初始化了libapp.so的默认路径,通过将libapp.so的默认路径添加到shellArgs数组,最后调用FlutterJNI.nativeInit()方法将这些信息传递到c++层,分析到这里,我们可以设想一下,如果我们将自定义的libapp.so路径添加到数组中,引擎就会成功加载自定义路径下的libapp.so文件,为了验证这一想法,我们继续跟踪源码,发现一个重要的文件/platform/posix/nativelibraryposix.cc,下图是NativeLibrary类的构造方法
NativeLibrary::NativeLibrary(const char* path) {
::dlerror();
FML_DLOG(ERROR)<< "so path "<< path;
handle_ = ::dlopen(path, RTLD_NOW);
if (handle_ == nullptr) {
FML_DLOG(ERROR) << "Could not open library '" << path << "' due to error '"
<< ::dlerror() << "'.";
}
}
构造方法中调用了handle_ = ::dlopen(path, RTLD_NOW),这正是我们熟悉的加载动态链接库文件的函数,通过打印日志发现path就是我们自定义的libapp.so路径。
综上,我们只需要把自定义的libapp.so路径加入到shellArgs数组中,Flutter引擎就可以成功加载从而实现Flutter代码的动态更新。
Flutter界面初始化主要包括FlutterView的初始化、FlutterNativeView的初始化、FlutterJNI的初始化以及bundle的初始化,FlutterJNI.runBundleAndSnapshotFromLibrary()方法为Native方法,对应的JNI函数是shell/platform/android/platformviewandroid_jni.cc文件中的RunBundleAndSnapshotFromLibrary(),下面我们看下这个函数的具体实现
static void RunBundleAndSnapshotFromLibrary(JNIEnv* env,
jobject jcaller,
jlong shell_holder,
jstring jBundlePath,
jstring jEntrypoint,
jstring jLibraryUrl,
jstring jCustomAssetPath,
jobject jAssetManager) {
auto asset_manager = std::make_shared<flutter::AssetManager>();
asset_manager->PushBack(std::make_unique<flutter::APKAssetProvider>(
env, // jni environment
jAssetManager, // asset manager
fml::jni::JavaStringToString(env, jBundlePath)) // apk asset dir
);
//后面代码省略
}
AssetManager顾名思义资源管理,熟悉Android的应该都知道Android原生都有一个专门的资源管理器,而这里的AssetManager则是负责Flutter的asset目录下资源的加载,AssetManager里面维护了一个AssetResolver的队列,AssetResolver是一个接口,我们看一下它的定义
class AssetResolver {
public:
AssetResolver() = default;
virtual ~AssetResolver() = default;
virtual bool IsValid() const = 0;
FML_WARN_UNUSED_RESULT
virtual std::unique_ptr<fml::Mapping> GetAsMapping(
const std::string& asset_name) const = 0;
private:
FML_DISALLOW_COPY_AND_ASSIGN(AssetResolver);
};
GetAsMapping方法返回Mapping对象的地址,而Mapping的作用主要是获取flutter_assets目录下的资源在内存中的地址和大小,显然AssetResolver的作用就是读取资源,APKAssetProvider正是实现了AssetResolver接口才拥有了在Android平台下读取Flutter资源的能力,但是问题是我们怎么实现AssetManager加载自定义路径下的资源呢?通过研究代码发现引擎中有一个重要的类DirectoryAssetBundle,这个类同样实现了AssetResolver的接口,因此我们自定义一个DirectoryAssetBundle对象,然后将它加入AssetManager维护的队列中就可以读取自定义路径下的资源了。下图是代码实现
static void RunBundleAndSnapshotFromLibrary(JNIEnv* env,
jobject jcaller,
jlong shell_holder,
jstring jBundlePath,
jstring jEntrypoint,
jstring jLibraryUrl,
jstring jCustomAssetPath,
jobject jAssetManager) {
auto asset_manager = std::make_shared<flutter::AssetManager>();
asset_manager->PushBack(std::make_unique<flutter::APKAssetProvider>(
env, // jni environment
jAssetManager, // asset manager
fml::jni::JavaStringToString(env, jBundlePath)) // apk asset dir
);
asset_manager->PushFront(std::make_unique<DirectoryAssetBundle>(fml::Duplicate(fml::OpenDirectory((fml::jni::JavaStringToString(env,jCustomAssetPath)).c_str(), false, fml::FilePermission::kRead).get())));
//后面代码省略
}
自定义asset路径可以通过上文时序图中的方法调用链增加入参传递到JNI函数中,然后将DirectoryAssetBundle加入队列头部,这样就会优先读取我们自定义路径下的资源
混合栈启动会执行DartExecutor.executeDartEntrypoint()方法,此方法接收DartEntryPoint对象,我们将自定义的asset路径存入此对象,然后顺着图中的方法调用链交给AssetManager处理,最终成功实现资源的动态加载
1、独立的Flutter版本号。
对应APP版本号,在Flutter业务端会维护一个Flutter版本号,此版本号作为接口入参传给后端作为A/B测试的条件之一
2、Flutter sdk版本号
在宿主APP的配置信息中会维护当前Flutter业务依赖的Flutter sdk版本号,分布更新包时会对Flutter sdk版本号进行校验,版本号相同则进行更新,不相同则放弃更新
3、增加Flutter channel版本号
Flutter channel包含dart代码和原生代码,如果动态更新包涉及到Flutter channel代码的改动,发布到线上后,线上APP只实现了dart端代码的更新,而channel对应的原生端通道代码还是旧版本的,这两端代码的不同步就会导致APP运行出现异常 ,所以我们在宿主中维护了一个Flutter channel版本号,每次发布Flutter更新包时,线上APP都会将自身的Flutter channel版本号和更新包的Flutter channel版本号进行比较,相同则进行更新,不相同则放弃更新
1、对比基准Apk和新Apk生成差分包,差分包会压缩成zip格式,防止运营商拦截
2、补丁发布系统上传补丁发布
3、客户端下载补丁包并校验合成
4、APP重启生效
前端多APP的Patch管理与发布平台
资源复用,提升开发效率是我们探索的初衷,在前端技术不断变化的浪潮中,让我们一起努力,不断更新和迭代技术,更高效的满足各种业务需求