To Be An Artist Engineer.

0%

计算机性能和两个因素有关:响应时间和吞吐率。吞吐量和硬件有关,通过堆硬件就可以提高,重点还是响应时间

响应时间 = 指令数 * 指令执行的周期数(CPI) * CPU时钟周期时间

  • 指令数和代码质量有关,垃圾代码往往生成的指令会有很多冗余
  • CPI是指令执行的周期数,现代CPU可以通过流水线等技术优化cpu cycle
  • CPU时钟周期时间是CPU主频决定的,主频越大,执行时间越短

cpu内部有一个晶体振荡器(简称晶振),每一次滴答就是一个时钟周期时间,计算方式为1/频率,比如cpu为2.8G hz,那周期时间就是1/2.8G,频率越大,cpu工作越快。

程序运行的时间统计

最常用的是计算时间差:程序结束的时间点-程序开始的时间点,这种时间称为wall clock time或者Ellapsed time。

这种计算方式有误差,比如在linux上计算wall clock time:

1
2
3
4
5
6
root@hwsrv-998587:~# time seq 100000 | wc -l
100000

real 0m0.011s // wall clock time
user 0m0.003s //**用户态**执行程序耗费的时间
sys 0m0.002s //**内核态**执行程序耗费的时间

程序真正执行的时长是user+sys,很明显比real小,因为cpu不停地在不同的程序间切换,所以程序的执行时间会偏大

wct

但是上面的现象也不是绝对的,比如在多核设备上,user和sys可能会同时执行,那最终的real可能就会小于user+sys

功耗

芯片本质上就是一个巨大的晶体管迷宫,通过控制开关来实现复杂运算。制程越短,晶体管的密度就越大,相应的性能就越好,但是由此可能会带来负面影响:晶体管散热不及时导致工作出错或者被干脆被烧毁,同时功耗也会增加,功耗的计算公式为:

功耗 ~= 1/2 ×负载电容×电压的平方×开关频率×晶体管数量

gh

其他提升性能的方式

既然晶体管数量提升有上限,那提升性能就只能在软件层面多优化了,主要是对CPI的优化,包括:

  • 并行执行,核心思想就是分治,优化后的执行时间 = 受优化影响的执行时间 / 加速倍数 + 不受影响的执行时间(阿姆达尔定律),需要注意的是分治最后的汇总是不并行的,所以如果汇总时间长那优化的效果就会有限)

    concurrent

  • 加快大概率事件,gpu性能比cpu好就是基于此

  • 通过流水线提升性能,将执行的指令拆分然后并行执行

  • 通过预测提升性能, 比如在条件语句执行前可以通过提前预测判断语句一定会执行,于是就可以提前运行语句代码从而加快执行速度

一、单Navigator模式

首先看下NavigatorState build方法的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@override
Widget build(BuildContext context) {
assert(!_debugLocked);
assert(_history.isNotEmpty);
// Hides the HeroControllerScope for the widget subtree so that the other
// nested navigator underneath will not pick up the hero controller above
// this level.
return HeroControllerScope.none(
child: Listener(
onPointerDown: _handlePointerDown,
onPointerUp: _handlePointerUpOrCancel,
onPointerCancel: _handlePointerUpOrCancel,
child: AbsorbPointer(
absorbing: false, // it's mutated directly by _cancelActivePointers above
child: FocusScope(
node: focusScopeNode,
autofocus: true,
child: UnmanagedRestorationScope(
bucket: bucket,
child: Overlay(
key: _overlayKey,
initialEntries: overlay == null ? _allRouteOverlayEntries.toList(growable: false) : const <OverlayEntry>[],
),
),
),
),
),
);
}
}

可以发现是靠Overlay来实现路由管理功能的。

以Navigator.push为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@optionalTypeArgs
Future<T?> push<T extends Object?>(Route<T> route) {
_pushEntry(_RouteEntry(route, initialState: _RouteLifecycle.push));
return route.popped;
}

void _pushEntry(_RouteEntry entry) {
....
_history.add(entry);
_flushHistoryUpdates();
....
}

void _flushHistroyUpdates({bool rearrangeOverlay = true}){
entry.handlePush(
navigator: this,
previous: previous?.route,
previousPresent: _getRouteBefore(index - 1, _RouteEntry.isPresentPredicate)?.route,
isNewFirst: next == null,
);
if (rearrangeOverlay) {
overlay?.rearrange(_allRouteOverlayEntries);
}
}

void handlePush({ required NavigatorState navigator, required bool isNewFirst, required Route<dynamic>? previous, required Route<dynamic>? previousPresent }) {
....
route.install();
....
}

具体调用过程如下:

Navigator创建_RouteEntry并添加到_history列表里

遍历_history列表取出新添加的Entry并调用Route的install方法,创建OverlayEntry实例

最后,调用OverlayStaterearrange方法把新创建的OverlayEntry插入到Overlay里,完成路由页面的添加

Navigator其他api方法都是类似的原理,本质上都是通过管理_RouteEntry列表来间接实现对Overlay的管理

二、FlutterBoost的多Navigator模式

FlutterBoost里的Widget结构为:

BoostContainerManager

​ |- Overlay

​ |- BoostNavigator

​ |- BoostNavigator

BoostNavigator是自定义的Navigator实例,每个原生容器(Activity、ViewController)都对应一个BoostNavigator实例并且被作为一个OverlayEntry插入到BoostContainerManager下的Overlay里。

下面是一些具体细节的剖析:

  1. 传递给MaterialApp的routes,onGenerateRoute参数怎么在这个模式下起作用呢?

    在MaterialApp里有一个builder参数,它是一个函数,其中第一个Widget child参数就是Navigator,它的onGenerateRoute参数是通过MaterialApp里的routes,home和onGenerateRoute等参数初始化的。

    BoostContainerManager持有了这个Navigator实例,但是没有把它插入Widget Tree,而是用来生产BoostNavigator,从而保证诸如Navigator.pushNamed等官方api能正常使用。

  2. FlutterBoost是怎么管理PopupRoute的?

    在showDialog等方法中默认会有一个useRootNavigator的参数,顾名思义它会优先使用Widget树中最顶层的Navigator。而在FlutterBoost中RootNavigator被人为移除了,普通页面拿到的Navigator其实就是BoostNavigator实例,这样在单个原生容器的上下文中PageRoute和PopupRoute的管理就统一了起来。如果不这样做会有什么后果呢:

    showDialog:使用RootNavigator

    popDialog:必须要使用Navigator.of(context,rootNavigator:true),如果使用Navigator.of(context)那拿到的是BoostNavigator实例,是无法移除对话框的。

一、文件压缩

Linux中常见的文件压缩算法有三种:gzip bzip2 xz ,它们的压缩率逐渐上升,但是压缩耗时也逐渐增加。

  1. gzip

压缩命令:gzip -9 -c filename > filename.gz

-9表示压缩比,理论上可以指定1-9,默认为6

解压命令:gzip -d filename.gz

查看命令:zcat filename.gz

  1. bzip2

压缩命令:bzip2 -9 -c filename > filename.bz2

-9表示压缩比,理论上可以指定1-9,默认为6

解压命令:bzip2 -d filename.bz2

查看命令:bzcat filename.bz2

  1. xz

压缩命令:xz -9 -c filename > filename.xz

-9表示压缩比,理论上可以指定1-9,默认为6

保留原文件名压缩指令: xz -k filename , 该命令等价于xz -c filename > filename.xz

解压命令:xz -d filename.xz

查看命令:xzcat filename.xz

二、目录压缩

压缩: tar [压缩算法] -c -v -f filename.tar.[压缩后缀] --exclude=filepath/a --exclude=filepath/b

[压缩算法]指的就是gzip,bzip2,xz三种之一,分别对应-z, -j , -J , 三者只能取一个或者不指定。

[压缩后缀]要和压缩算法一致,取gz,bz2,xz其中之一。如果不指定压缩后缀则称生成文件为tarfile, 指定了则称之为tarball

–exclude指定目录里的哪些文件不压缩到文件中

查看: tar -t -v -f filename.tar.[压缩后缀]

解压缩:tar -x -v -f filename.tar.[压缩后缀] -C [目录名称] ,如果不指定-C则解压缩到当前目录

利用管线命令与资料流复制
1
2
cd destDir
tar -cvf - sourcedir | tar -xvf -

利用上述命令可以快速把源目录的文件复制到目标目录,这里的 - 可以看成是内存中的一块缓存区域,即先把源文件压缩写入该缓存,再从该缓存输出到目标目录

原文链接:Supporting the new Android plugins APIs

随着1.12版本的发布,插件API也发生了变更。旧的API基于PluginRegistry.Registrar,这种方式不会立即被废弃,但是我们仍然鼓励开发者尽量使用基于FlutterPlugin的新的集成方式。

和旧的API相比,新的API更加依赖于组件的生命周期。比如说PluginRegistry.Registrar.activity()可能返回的是null,因为FlutterView可能并没有和任何Activity绑定。

换句话说,采用旧的API开发插件可能会产生不可预知的问题。目前,Flutter官方提供的插件均已完成迁移。

升级步骤

  1. 插件类实现FlutterPlugin接口,你也可以把FlutterPlugin和MethodCallHandler用两个类分别实现。插件需要保留registerWith方法来兼容使用老版本插件API的App。使用新插件的App将会调用FlutterPlugin的接口onAttachedToEngine来初始化插件,而未升级插件API的老版本的App调用registerWith方法实现注册。此外,对于所有公共非接口定义方法都应该补充文档。在混合开发的场景下,这些方法会对开发者完全开放。

  2. (可选)如果插件需要获取Activity引用,需要实现ActivityAware。

  3. (可选)如果需要持有后台服务实例,需要实现ServiceAware。

  4. 入口Flutter Activity需要继承v2 embedding FlutterActivity。具体可以看另一篇升级准1.12的Android项目,需要注意的是新版本的插件类最好提供一个公共构造方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    package io.flutter.plugins.firebasecoreexample;

    import io.flutter.embedding.android.FlutterActivity;
    import io.flutter.embedding.engine.FlutterEngine;
    import io.flutter.plugins.firebase.core.FirebaseCorePlugin;

    public class MainActivity extends FlutterActivity {
    // You can keep this empty class or remove it. Plugins on the new embedding
    // now automatically registers plugins.
    }
  5. (可选)如果移除了MainActivity,需要在AndroidManifest.xml中使用io.flutter.embedding.android.FlutterActivity

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
      <activity android:name="io.flutter.embedding.android.FlutterActivity"
    android:theme="@style/LaunchTheme"
    android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale"
    android:hardwareAccelerated="true"
    android:windowSoftInputMode="adjustResize">
    <meta-data
    android:name="io.flutter.app.android.SplashScreenUntilFirstFrame"
    android:value="true" />
    <intent-filter>
    <action android:name="android.intent.action.MAIN"/>
    <category android:name="android.intent.category.LAUNCHER"/>
    </intent-filter>
    </activity>

  6. (可选)在MainActivity同级目录下创建继承v1 embedding API的EmbeddingV1Activity.java类来测试是否兼容v1 embedding的插件。需要注意的时候,必须要手动注册插件而不是使用GeneratedPluginRegistrant。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    package io.flutter.plugins.batteryexample;

    import android.os.Bundle;
    import dev.flutter.plugins.e2e.E2EPlugin;
    import io.flutter.app.FlutterActivity;
    import io.flutter.plugins.battery.BatteryPlugin;

    public class EmbeddingV1Activity extends FlutterActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    BatteryPlugin.registerWith(registrarFor("io.flutter.plugins.battery.BatteryPlugin"));
    E2EPlugin.registerWith(registrarFor("dev.flutter.plugins.e2e.E2EPlugin"));
    }
    }

  7. 添加<meta-data android:name="flutterEmbedding" android:value="2"/>到AndroidManifest.xml中,这样项目就会使用v2 embedding。

  8. (可选) 如果创建了EmbeddingV1Activity类,需要吧它加到AndroidManifest.xml文件中。

    1
    2
    3
    4
    5
    6
    7
    <activity
    android:name=".EmbeddingV1Activity"
    android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale"
    android:hardwareAccelerated="true"
    android:windowSoftInputMode="adjustResize">
    </activity>

测试插件

我们鼓励对插件进行充分测试,虽然不是必要的,但是我们鼓励开发者这么做。


  1. 把build.gradle文件中的android.support.test替换成androidx.test。

    1
    2
    3
    4
    5
    defaultConfig {
    ...
    testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    ...
    }
    1
    2
    3
    4
    5
    6
    7
    dependencies {
    ...
    androidTestImplementation 'androidx.test:runner:1.2.0'
    androidTestImplementation 'androidx.test:rules:1.2.0'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
    ...
    }
  2. <plugin_name>/example/android/app/src/androidTest/java/<plugin_path>目录为MainActivityEmbeddingV1Activity添加测试文件。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    package io.flutter.plugins.firebase.core;

    import androidx.test.rule.ActivityTestRule;
    import dev.flutter.plugins.e2e.FlutterRunner;
    import io.flutter.plugins.firebasecoreexample.MainActivity;
    import org.junit.Rule;
    import org.junit.runner.RunWith;

    @RunWith(FlutterRunner.class)
    public class MainActivityTest {
    // Replace `MainActivity` with `io.flutter.embedding.android.FlutterActivity` if you removed `MainActivity`.
    @Rule
    public ActivityTestRule<MainActivity> rule = new
    ActivityTestRule<>(MainActivity.class);
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    package io.flutter.plugins.firebase.core;

    import androidx.test.rule.ActivityTestRule;
    import dev.flutter.plugins.e2e.FlutterRunner;
    import io.flutter.plugins.firebasecoreexample.EmbeddingV1Activity;
    import org.junit.Rule;
    import org.junit.runner.RunWith;

    @RunWith(FlutterRunner.class)
    public class EmbeddingV1ActivityTest {
    @Rule
    public ActivityTestRule<EmbeddingV1Activity> rule =
    new ActivityTestRule<>(EmbeddingV1Activity.class);
    }
  3. 添加e2eflutter_driverdev_dependencies到/pubspec.yaml/example/pubspec.yaml

    1
    2
    3
    e2e: ^0.2.1
    flutter_driver:
    sdk: flutter
  4. 更新Flutter的最低支持版本,目前所有新v2 embedding插件最小支持版本均已升级至1.12.13+hotfix.6版本。

    1
    2
    3
    4
    environment:
    sdk: ">=2.0.0-dev.28.0 <3.0.0"
    flutter: ">=1.12.13+hotfix.6 <2.0.0"

  5. <plugin_name>/test目录下创建测试文件<plugin_name>_e2e.dart。下面的测试针对的是插件是否成功注册到v2 embedder。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import 'package:flutter_test/flutter_test.dart';
    import 'package:battery/battery.dart';
    import 'package:e2e/e2e.dart';

    void main() {
    E2EWidgetsFlutterBinding.ensureInitialized();

    testWidgets('Can get battery level', (WidgetTester tester) async {
    final Battery battery = Battery();
    final int batteryLevel = await battery.batteryLevel;
    expect(batteryLevel, isNotNull);
    });
    }
  6. 也可以在Native运行测试:

    1
    2
    3
    4
    5
    cd <plugin_name>/example
    flutter build apk
    cd android
    ./gradlew app:connectedAndroidTest -Ptarget=`pwd`/../../test/<plugin_name>_e2e.dart

基础插件

只实现FlutterPlugin接口

1
2
3
4
5
6
7
8
9
10
11
public class MyPlugin implements FlutterPlugin {
@Override
public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) {
// TODO: your plugin is now attached to a Flutter experience.
}

@Override
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
// TODO: your plugin is no longer attached to a Flutter experience.
}
}

在onAttachedToEngine方法内初始化插件,在onDetachedFromEngine方法内清理插件引用。

FlutterPluginBinding有两个很重要的方法:

  • binding.getFlutterEngine(),返回的是绑定的FlutterEngine对象,可以通过它继而拿到DartExecutor,FlutterRenderer等。
  • bingding.getApplicationContext(),返回的是Applicaton‘s Context。

UI/Activity 插件

如果需要与UI交互,比如申请权限或者改变Android UI,那么你需要实现ActivityAware接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class MyPlugin implements FlutterPlugin, ActivityAware {
//...normal plugin behavior is hidden...

@Override
public void onAttachedToActivity(ActivityPluginBinding activityPluginBinding) {
// TODO: your plugin is now attached to an Activity
}

@Override
public void onDetachedFromActivityForConfigChanges() {
// TODO: the Activity your plugin was attached to was
// destroyed to change configuration.
// This call will be followed by onReattachedToActivityForConfigChanges().
}

@Override
public void onReattachedToActivityForConfigChanges(ActivityPluginBinding activityPluginBinding) {
// TODO: your plugin is now attached to a new Activity
// after a configuration change.
}

@Override
public void onDetachedFromActivity() {
// TODO: your plugin is no longer associated with an Activity.
// Clean up references.
}
}

为了和Activity交互,你的ActivityAware插件需要经历4个阶段。

  • 首先是onAttachedToActivity,此时可以通过ActivityPluginBinding获取Activity实例和一系列的回调。
  • 当发生configuration change时,你必须在onDetachedFromActivityConfigChanges回调中做一些清理工作。
  • 在onReattachedToActivityForConfigChanges回调中重新做一些初始化工作。
  • 在onDetachedFromActivity中必须把所有Activity引用都清理掉,返回到无UI配置中。

笔者家里的电信宽带刚申请换上了公网ip, 加上有一台闲置的windows电脑,所以想到了把它打造成家庭nas。

中间经历了各种情况,踩了很多坑,现记录如下:


公网ip无用

家里网络是电信网关+TPLink, 电信网关负责拨号获取ip,TPLink做路由转发。在成功切换成公网ip后,笔者兴致冲冲的去ping, 结果发现居然是req timeout,尝试放开各种端口发现也不通。笔者所用网关型号为tewa-600NEM,后来一顿搜索,解决了这个问题:

  • 打开网关隐藏配置信息: 192.168.1.1/dumpcfg.conf (192.168.1.1是网关地址)下载隐藏配置,用记事本之类软件打开此文件,搜索“telecomadmin”即可,密码格式为”telecomadmin********”
  • 获取密码后登陆网关, 用户名为useradmin, 密码用上一步获取的密码就可以进入超级模式
  • 选择宽带线路,把路由模式从路由改为桥接
  • 打开TPLink, 选择上网模式为拨号上网,拿到ip
  • Ping,发现可以ping通,这个时候拿到的才是公网ip

笔者发现天翼网关拿到的公网ip不仅ping不同,所有端口默认都是ban掉的,而且和路由拨号拿到的ip号段也不一样

部署私有网盘

笔者选择了国产的一款网盘:可道云,选择了它的免费版本。部署过程如下:

  • 下载xampp, 这是一个工具集,包含了Apache Server, MySql, FileZellia等,但其实主要用到的还是Apache Server, MySql
  • 下载KodExplorer, 加压到xampp目录下替换htdocs文件夹里的所有内容
  • 在xampp控制面板启动即可,默认端口为80,可以根据需要修改

域名

笔者在阿里云万网注册了一个域名,但是直接绑定家里的ip感觉不妥,因为家里的公网ip是不断变化的,如果绑定某一个域名肯定要频繁改,为了规避麻烦笔者采用了DDNS方案。DDNS首先想到的就是花生壳,但是这货注册太麻烦,直接弃用。笔者直接采用tplink自带的ddns, 绑定了一个域名A.tpddns.cn,然后在万网域名解析里加上了一个CNAME记录指向了这个A.tpddns.cn,至此,域名的问题就算是解决了。

虚拟服务器配置

家里的windows server是内网ip, 怎么供外网访问呢?在路由器设置里笔者发现了两种方案:

  • DMZ主机,把windows server直接暴露到外网,简单但是安全性低
  • NAT, 通过端口映射把APACHE SERVER监听的端口映射到外部某个端口

笔者选择了NAT方案,配置很简单,不再赘述

备份

KodExplorer并没有提供一键备份功能,所以笔者选择了一个简易的备份方案

先说说设备,笔者手上有一台开发用的mac,搭配阿里云oss存储

开启windows server的CIFS(SMB)服务:

  • 进入windows server, 选择需要共享的盘符,右键->属性->共享->高级共享移除Everyone,添加用户A(需要密码)
  • 打开”关闭或打开windows功能”,勾选smb, cifs,保存

mac加载盘符

1
2
mkdir smb
mount_smbfs //${用户名}:${密码}/${盘符名称} ./smb

进入盘符备份

1
2
cd smb
zip -r -FS backup.zip ${数据目录}

上传oss

1
2
3
4
##安装oss2
pip install oss2
##上传(oss.py可以直接在官网demo中找到,自己填上AccessId和AccessSecret即可)
python oss.py smb/backup.zip

上述任务可以做成定时任务,Linux上可以用crontab, Mac上推荐用launchctl, 下面推荐launchctl的方法

1
2
3
4
cd ~/Library/LaunchAgents
touch com.kod.backup.plist
launchctl load -w com.kod.backup.plist
launchctl start com.kod.backup.plist

如果要停止定时任务

1
2
launchctl stop com.kod.backup.plist
launchctl unload -w com.kod.backup.plist

下面是笔者写的com.kod.backup.plist内容,每隔10个小时执行一次部署脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.kod.backup</string>
<key>ProgramArguments</key>
<array>
##这是执行脚本的位置
<string>/Users/xxx/xxx/backup.sh</string>
</array>
##周期性执行,可以替换为特定时间点执行StartCalendarInterval
<key>StartInterval</key>
##单位为秒
<integer>36000</integer>
##日志目录
<key>StandardOutPath</key>
<string>/Users/xxx/xxx/run.log</string>
##错误日志目录
<key>StandardErrorPath</key>
<string>/Users/xxx/xx/run.err</string>
</dict>
</plist>

这里有几个点需要注意:

  • 脚本是否具有可执行权限,如果没有直接chmod +x 解决
  • 如果用到了python,记得使用/usr/local/bin/python而不是直接使用python,否则会报找不到oss2的错误

摘要

Flutter作为Google推出的新一代跨平台开发框架,具有热启动调试、速度快等优势,可以把原来需要Android和iOS两端人员的工作量压缩到一端,从而大大提高开发效率。
但是超级账号作为一款已经上线的成熟应用,引入Flutter面临混合开发的技术难题。目前混合开发有两种方案:

Google官方方案:

wiki地址:https://github.com/flutter/flutter/wiki/Add-Flutter-to-existing-apps

主要过程如下:

  • 在项目的父目录调用flutter create -t module “#modulename”创建flutter module项目

  • 在flutter module中进行flutter模块的开发

  • 在主项目中依赖flutter module,最终打包上线

该方案要求native端强制依赖flutter subproject, 侵入性较强,但是整合比较简单。

闲鱼方案:

如图:

这种方案是把flutter模块抽成一个独立的library并发布到远程maven, native端开发人员可以直接依赖,从而做到解耦。
闲鱼的技术人员没有采用官方推荐的打包方案而是自己创建了一套打包流程:

Flutter

  • dart的release编译方式为aot,会生成手机可运行的ARM代码,对应的文件为icudtl.dat、isolate_snapshot_data、isolate_snapshot_instr、vm_snapshot_data、vm_snapshot_instr。
  • flutter_assets即flutter项目中引用的字体和图片等资源
  • flutter_jar,包含lib_flutter.so和dart vm等

Flutter plugin

flutter的包依赖,如image_picker、fluttertoast等,这些依赖默认不会打到flutter library中,必须手动编译生成aar

TL.DR

闲鱼是采用脚本自定义打包内容,但是把apk反编译后发现gradle默认生成的aar也可以满足需要(多了一些无用的资源文件)。在此基础上为了方便flutter和native开发的自由切换,这里加入了组件化的一些特性:

  • 添加一键切换library和application的开关,在开发flutter阶段采用application的形式,从而充分利用flutter热重启的优点。而在集成阶段则切换成library, 把flutter和flutter plugins的依赖导入到项目中即可。

在根目录的gradle.properties文件中加入开关:

1
isModule = false

在app/build.gradle中进行适配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (isModule.toBoolean()) {
apply plugin: 'com.android.library'
} else {
apply plugin: 'com.android.application'
}

android{
...
sourceSets {
main {
if (isModule.toBoolean()) {
manifest.srcFile 'src/main/module/AndroidManifest.xml'
} else {
manifest.srcFile 'src/main/AndroidManifest.xml'
}
}
}
}

打包使用自定义task,包含编译项目和aar包的收集:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
task buildDebug(type: Copy, dependsOn: "assembleDebug") {
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) }
}

from("../../build/app/outputs/aar") {
include "app-debug.aar"
rename "app-debug.aar", "${rootProject.projectDir.parentFile.name}-debug.aar"
}
plugins.each { name, path ->
from("../../build/$name/outputs/aar") {
include "$name-debug.aar"
}
}
if(outputDir.isEmpty()){
into './libs'
}else{
into outputDir
}
}
  • Fat aar模式
    闲鱼模式对Flutter和Flutter plugin是分别依赖的,这样可能会有很多aar包,后续可以考虑aar的合并。目前这块没有官方支持,而三方库有一些缺陷(gradle版本限制,api过时等),所以这块暂不考虑

注意事项

  • flutter在打包时会丢弃icudtl.dat文件,这是flutter用于处理text的一个底层库,所以运行时会发生”ICUContext null”错误,解决办法就是在flutter的android目录下手动把apk里的flutter_shared/打包icudtl.da文件拷贝到src/main/assets/目录下,这样打包就不会出错了。

  • 在应用启动时需要手动初始化Flutter运行时环境, 否则在跳到flutter页面时会报“NOT INITIALIZED”异常

  • Flutter release模式下只支持armV7架构,只有在debug模式下才支持x86和x86_64(暂时), 但是主工程默认支持所有abi模式(除了mips和mips64),所以如果在诸如x86架构上的手机上打开flutter页面可能导致崩溃。但是考虑到x86目前占有市场份额不足1%的现状,这个问题影响不是很大。

  • 除了依赖Flutter工程还需要把flutter依赖的三方库一并导入主项目,否则三方库的运行可能出现问题。

参考

https://zhuanlan.zhihu.com/p/40528502(闲鱼flutter混合工程持续集成的最佳实践)
https://flutter.io
https://github.com/flutter/flutter/wiki/Add-Flutter-to-existing-apps(flutter wiki)

Flutter版本: Flutter (Channel master, v1.9.1, on Mac OS X 10.14.4 18E226, locale zh-Hans-CN)

基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const Image({
Key key,
@required this.image,
this.frameBuilder,
this.loadingBuilder,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.filterQuality = FilterQuality.low,
}) : assert(image != null),
assert(alignment != null),
assert(repeat != null),
assert(filterQuality != null),
assert(matchTextDirection != null),
super(key: key);

上面是Image Widget的构造方法,其中四种属性是最常被使用的:

imagewidthheightfit
  • width和height表示的是控件的大小,如果为null则自适应图片本身的宽高或者由更外层的控件决定(Tight Layout Constraints)。如果设定的尺寸大小和图片本身的大小不一致,那就要根据fit属性来决定最终的显示。

  • fit表示的是图片伸缩类型,和Android中的scaleType类似,包含以下几种:

    fill contain cover fitWidth fitHeight scaleDown none

  • image属性为ImageProvider类型,官方提供了四种供开发者直接使用:

    NetworkImage AssetImage MemoryImage FileImage
    它们分别对应于Image的四种工厂方法: + `Image.network("")`,加载网络图片
    • Image.asset("",package:""),加载本地资源文件,需要注意的是图片文件必须在pubspec.yaml中注册。如果希望Flutter在不同分辨率下使用不同大小的图片则需要在文件夹下创建2.0x,3.0x等子文件夹,在这些文件夹中放入同名文件并且在yaml中注册。至于package属性则是在lib下创建图片文件夹时才会用到,这些都可以在官网上看到(在Flutter中添加资源和图片),本文不再赘述。
    • Image.memory(Uint8List),平时开发用的较少,表示直接加载字节数组
    • Image.file(""),表示直接加载本地图片,一般会配合path_provider使用

图片加载

在整个图片加载的过程中起决定性作用的是ImageProvider类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
abstract class ImageProvider<T> {

ImageStream resolve(ImageConfiguration configuration) {
assert(configuration != null);
//创建ImageStream
final ImageStream stream = ImageStream();
T obtainedKey;
///省略了error handle的逻辑
dangerZone.runGuarded(() {
Future<T> key;
try {
//获取ImageProvider对象
key = obtainKey(configuration);
} catch (error, stackTrace) {
handleError(error, stackTrace);
return;
}
key.then<void>((T key) {
obtainedKey = key;
//首先从缓存中去取,如果取不到就调用load(key)方法加载图片
final ImageStreamCompleter completer = PaintingBinding.instance
.imageCache.putIfAbsent(key, () => load(key), onError: handleError);
if (completer != null) {
stream.setCompleter(completer);
}
}).catchError(handleError);
});
return stream;
}

Future<T> obtainKey(ImageConfiguration configuration);

@protected
ImageStreamCompleter load(T key);
}

可以看到其中比较关键的几个方法:resolve, obtainKey, load

下面是上面代码块的拆解分析:

  • 生成ImageStream
  • 生成ImageProvider对象:key
  • 以key为键去ImageCache中查看是否已经有缓存(ImageStreamCompleter)对象,如果有就直接返回,如果没有就调用load(key)方法生成对应的ImageStreamCompleter并缓存起来
  • 把上一步获取的ImageStreamCompleter对象设置给ImageStream
1
2
3
4
5
6
7
8
9
10
11
12

///ImageStream setCompleter方法:

void setCompleter(ImageStreamCompleter value) {
assert(_completer == null);
_completer = value;
if (_listeners != null) {
final List<ImageStreamListener> initialListeners = _listeners;
_listeners = null;
initialListeners.forEach(_completer.addListener);
}
}

可以看到的是ImageStream把所有监听器都注册到ImageStreamCompleter,这样在图片加载成功后就可以回调给所有的监听者,这其中就包括_ImageState:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

///ImageStreamCompleter setImage方法:

void setImage(ImageInfo image) {
_currentImage = image;
if (_listeners.isEmpty)
return;
// Make a copy to allow for concurrent modification.
final List<ImageStreamListener> localListeners =
List<ImageStreamListener>.from(_listeners);
for (ImageStreamListener listener in localListeners) {
try {
listener.onImage(image, false);
} catch (exception, stack) {
reportError(
context: ErrorDescription('by an image listener'),
exception: exception,
stack: stack,
);
}
}
}

可以看到Flutter中Image的加载还是很简洁的,基本上可以简化为Image控件通过ImageStream注册监听器到真正的图片加载器ImageStreamCompleter,然后在图片成功获取后通过回调的方式返回给Image Widget

下面是笔者绘制的NetWorkImage图片加载UML图:(其他类型的ImageProvider加载过程都类似)

图片优化

Flutter中的Image组件使用十分简洁,但是也有一些缺陷。

1、没法定制Error Widget

Image没有提供error callback,如果开发者想定制错误布局需要自己自定义ImageProvider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class CustomNetworkImage extends ImageProvider<NetworkImageSSL> {
const CustomNetworkImage(this.url,
{this.scale = 1.0, this.headers, this.imageErrorListener})
: assert(url != null),
assert(scale != null);

final String url;

final double scale;

final Map<String, String> headers;

final ImageErrorListener imageErrorListener;

@override
ImageStreamCompleter load(NetworkImageSSL key) {
MultiFrameImageStreamCompleter multiFrameImageStreamCompleter = MultiFrameImageStreamCompleter(
codec: _loadAsync(key),
scale: key.scale,
informationCollector: () {
List<DiagnosticsNode> informationCollector;
informationCollector.add(DiagnosticsProperty('Image provider', this));
informationCollector.add(DiagnosticsProperty('Image key', key));
return informationCollector;
});

///添加Error Callback
ImageStreamListener listener;
listener = ImageStreamListener((imageInfo, ret) {
//移除监听器
multiFrameImageStreamCompleter.removeListener(listener);
}, onError: (ex, stack) {
multiFrameImageStreamCompleter.removeListener(listener);
//回调给上层控件
imageErrorListener?.call(ex, stack);
});

multiFrameImageStreamCompleter.addListener(listener);
return multiFrameImageStreamCompleter;
}
}

如果需要添加占位图,可以考虑使用Image控件的loadingBuilder属性,Flutter还提供了下载进度的回调通知

1
2
3
4
5
typedef ImageLoadingBuilder = Widget Function(
BuildContext context,
Widget child,
ImageChunkEvent loadingProgress,
);

2、缓存不友好

Flutter中ImageCache只实现了内存缓存(默认最多缓存1000张图片和100M内存),并且缓存的是ImageStreamCompleter(不是ui.Image)对象。

如果想要自定义缓存数量或者最大内存可以在Root Widget里定义:

1
2
3
4
5
6
7
@override
void initState() {
super.initState();
//最多100张图片和150M内存
PaintingBinding.instance.imageCache.maximumSize = 100;
PaintingBinding.instance.imageCache.maximumSizeBytes = 150<<20;
}

ImageCache是没有磁盘缓存的,这就意味着可能会重复加载图片,从而造成流量浪费。要添加磁盘缓存,需要定制ImageProvider(下面是伪码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Future<ui.Codec> _loadAsync(NetworkImageSSL key) async {
assert(key == this);

/////////////////在加载前判断本地是否有缓存/////////////////
if(diskCached(key.url)){
return getBytes(key.url);
}
/////////////////在加载前判断本地是否有缓存/////////////////

final Uri resolved = Uri.base.resolve(key.url);
final HttpClientRequest request = await _httpClient.getUrl(resolved);
headers?.forEach((String name, String value) {
request.headers.add(name, value);
});
final HttpClientResponse response = await request.close();
if (response.statusCode != HttpStatus.ok) {
throw new Exception(
'HTTP request failed, statusCode: ${response?.statusCode}, $resolved');
}

final Uint8List bytes = await consolidateHttpClientResponseBytes(response);
if (bytes.lengthInBytes == 0) {
throw new Exception('NetworkImageSSL is an empty file: $resolved');
}

/////////////////缓存到本地/////////////////
saveBytesToDisk(key.url,bytes);
/////////////////缓存到本地/////////////////
return await ui.instantiateImageCodec(bytes);
}

注意到上述代码最后一个过程:return await ui.instantiateImageCodec(bytes);

这段代码的含义是解析图片字节数组获取ui.Codec对象,但是可惜的是Flutter不会根据实际显示图片的大小进行解码。举个栗子,假如我要加载一张1000x1000大小的图片,而显示区域只有100x100,那解码所耗费的内存是按1000x1000算的,这显然会造成不必要的浪费。

笔者用一张图片做了测试:

优化的方案很简单:

1
2
3
4
5
6
7
8
Future<Codec> instantiateImageCodec(Uint8List list, {
int targetWidth,
int targetHeight,
}) {
return _futurize(
(_Callback<Codec> callback) => _instantiateImageCodec(list, callback, null, targetWidth ?? _kDoNotResizeDimension, targetHeight ?? _kDoNotResizeDimension)
);
}

只要在解码时传入targetWidth和targetHeight即可(Flutter 版本至少要大于1.5.4,低于此版本就没法使用该优化方案)

3、无法降低的内存占用

Flutter中的Image内存似乎存在着内存泄漏的危险,笔者做了一个简单的实验,即一张空白页面加载一张网络图片,下面是实验过程(数据均来自Dart Observatory):

打开页面

可以看到新生代内存占用为2.9M,老年代为0

销毁页面

可以看到新生代内存占用为0,老年代为2.9M,至此都是正常的,因为Dart VM的老年代就是存储较大内存对象的,然后笔者进行了手动GC操作,发现内存占用没发生变化,即老年代里的Image对象没有被回收。

销毁页面+手动清缓存
1
2
3
4
5
6
7
@override
void initState() {
super.initState();
//禁止缓存
PaintingBinding.instance.imageCache.maximumSize = 0;
PaintingBinding.instance.imageCache.maximumSizeBytes = 0;
}

起初笔者考虑到问题可能出在图片缓存上,因为缓存持有了图片对象可能导致内存无法被彻底回收,但是结果是
一样的,Image对象依旧无法被回收。

调用native方法回收

ImageStreamCompleter->ImageInfo->ui.Image->dispose->native 'Image_dispose'

笔者尝试了在页面销毁的时候销毁ui.Image对象,并且手动GC,结果是

可以看到已经看不到图片对象了,但是需要注意的是该方法会彻底回收ui.Image对象,所以如果应用到生产项目需要考虑到其他可能引用到ui.Image的地方,毕竟这个方案有点过于简单粗暴了。下面是参考伪码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class CacheableNetworkImage extends ImageProvider<NetworkImageSSL> {
CacheableNetworkImage(this.url,
{this.scale = 1.0, this.headers})
: assert(url != null),
assert(scale != null);

final String url;

final double scale;

final Map<String, String> headers;

///////////保存imageInfo对象////////////
ImageInfo imageInfo;
///////////保存imageInfo对象////////////

////////////页面销毁时调用///////////////
void dispose() {
imageInfo?.image?.dispose();
}
////////////页面销毁时调用///////////////

@override
ImageStreamCompleter load(NetworkImageSSL key) {
var multiFrameImageStreamCompleter = new MultiFrameImageStreamCompleter(
codec: _loadAsync(key),
scale: key.scale,
informationCollector: () {
List<DiagnosticsNode> informationCollector;
informationCollector.add(DiagnosticsProperty('Image provider', this));
informationCollector.add(DiagnosticsProperty('Image key', key));
return informationCollector;
});

///添加Error Callback
ImageStreamListener listener;
listener = ImageStreamListener((imageInfo, ret) {

/////////保存ImageInfo对象/////////
this.imageInfo = imageInfo;
/////////保存ImageInfo对象/////////

multiFrameImageStreamCompleter.removeListener(listener);
}, onError: (ex, stack) {
multiFrameImageStreamCompleter.removeListener(listener);
imageErrorListener?.call(ex, stack);
});

multiFrameImageStreamCompleter.addListener(listener);
return multiFrameImageStreamCompleter;
}
......................
}