JQuery高级技巧

1、jq –arg传递的变量select()没有硬编码值就不能工作吗?

1
2
3
4
Product_Personnel_Array=$(cat ${Product_Personnel_FILE_PATH} | ${JQ_EXEC} -r '.[]') # -r 去除字符串引号
echo ${Product_Personnel_Array} | ${JQ_EXEC} -r --arg branchLastCommitUserName "$branchLastCommitUserName" 'select(.git_name=="qian")'
echo ${Product_Personnel_Array} | ${JQ_EXEC} -r --arg branchLastCommitUserName "$branchLastCommitUserName" 'select(.git_name==$branchLastCommitUserName)'

2、shell - jq 处理json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 0、基础用法(验证通过✅)
FILE_PATH=~/Project/Bojue/mobile_flutter_wish/bulidScript/app_info.json
jq '.package_notification_argument_current.wechat.error.mentioned_list=2' $FILE_PATH

# 1.1、先测试修改数值时候(字符串),能否正确对应到key上(验证通过✅)
UpdateJsonKeyValue="1314"
jq --arg UpdateJsonKeyValue "$UpdateJsonKeyValue" '.package_notification_argument_current.wechat.error.mentioned_list=$UpdateJsonKeyValue' $FILE_PATH

# 1.2、先测试修改数值时候(数组),能否正确对应到key上
UpdateJsonKeyValue_Array=("a" "b" "c")
#UpdateJsonKeyValue="${UpdateJsonKeyValue_Array[*]}" # 这个是错误的❌
UpdateJsonKeyValue='["a", "b", "c", "d"]' # 必须转成这个才能通过✅
jq --arg UpdateJsonKeyValue "$UpdateJsonKeyValue" '.package_notification_argument_current.wechat.error.mentioned_list=$UpdateJsonKeyValue' $FILE_PATH

# 2、继续测试同时修改数值和key时候,能否正确对应到key上(验证失败❌)
UpdateJsonKey="package_notification_argument_current.wechat.error.mentioned_list"
UpdateJsonKeyValue=("a" "b" "c")
jq --arg UpdateJsonKey "$UpdateJsonKey" --arg UpdateJsonKeyValue "$UpdateJsonKeyValue" '.[$UpdateJsonKey]=$UpdateJsonKeyValue' $FILE_PATH


key要加[],值不用
1
2
jq '.test_group_1[] | select(.name == test_1-1).checksum = 1' $FILE_PATH

Flutter 与原生相关

一、使用与原生有关的三方库的注意点

1、trying to create a view with an unregistered type, unregistered view type

使用三方库的时候,如果该库与原生有关。如webview插件、百度地图插件等。如果直接使用,不对工程设置的话,则会报trying to create a view with an unregistered type, unregistered view type:

解决方案:在info.plist加入

1
<key>io.flutter.embedded_views_preview</key>

原因:

2、启动的时候使用rootBundle.loadString加载本地配置json\百度地图,报错:Unhandled Exception: Null check operator used on a null value

启动的时候使用rootBundle.loadString 加载本地配置json,但是直接加载会报错:
[ERROR:flutter/lib/ui/ui_dart_state.cc(199)] Unhandled Exception: Null check operator used on a null value

解决办法:加载本地资源以前增加一行代码:

WidgetsFlutterBinding.ensureInitialized(); //解决加载json错误

例子:

1
2
3
4
5
void main() async {
WidgetsFlutterBinding.ensureInitialized(); //解决加载json错误
globals.config = jsonDecode(await rootBundle.loadString(‘assets/config.json’));
runApp(MyApp());
}

二、浅析 Flutter 与 iOS 的视图桥梁 PlatformView

三、百度地图

百度地图-Flutter插件

1)iOS权限问题可参照iOS定位SDK手动部署说明
2)iOS头文件错误:

ios1.png

ios2.png

解决办法:Xcode-TARGETS-build settings-Allow Non-modular Includes In Famework Modules设置为YES。

Allow Non-modular Includes In Famework Modules

End

优雅的自定义返回

一、背景

从页面进入弹窗(全局WebView)。弹窗和页面不在同一路由栈上,从而使得当页面返回的时候,需要自己判断返回到哪。

二、场景示例

1
2
3
页面APage --> 页面BPage --> 弹窗CView --> 页面DPage

页面APage <-- 页面BPage <-- 弹窗CView <--

三、自定义返回

1、判断的核心

  • 判断的核心:在进入的时候标记下从哪来,点击返回的时候即回哪去。

2、必选处理的事及其初始主要代码

  • 必选处理的事:

    ①自定义返回按钮事件。

    ②传递进入时候的标记。

  • 处理事项的主要代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 进入页面 APage.swift
    func goPage() {
    let currentRoutePath = "xxx" // Replace with the actual value of currentRoutePath
    let viewController = BPage(fromRoutePath: currentRoutePath)
    self.navigationController?.pushViewController(viewController, animated: true)
    }

    // 返回页面 BPage.swift
    func goBack() {
    if fromRoutePath == "ShareWebView" {
    ShareWebViewRouteUtil.hide() // 全局webView视图
    } else {
    if isPresent {
    self.dismiss(animated: true, completion: nil)
    } else {
    self.navigationController?.popViewController(animated: true)
    }
    }
    }

3、所需值fromRoutePath的传入与使用

进入时候的标记赋值方案,主要有以下两种。

3.1、方案1(土)

进入页面的代码处标记。缺点:每个需要的地方的都需要写相应的代码。

3.2、方案2(精)

页面进行路由跳转的时候,将前一页的页面命名路由名传递给下一页。

1、添加页面跳转监听:进行对原本参数的添加,达到新值的传入目的。

2、跳转的目的页面将所有参数接收,待后续内部独立处理。

3、页面内部独立分解所有参数,并在返回时候充分使用。

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
// 页面跳转的监听
class AppNavigatorObserver {
// 在进入的时候,自动将当前路由路径添加上到传递给下个页面的所有参数中
@override
void didPush(Route route, Route? previousRoute) {
final Object? arguments = route?.settings.arguments;
if (arguments != null && arguments is Map<String, dynamic>) {
arguments['fromRoutePath'] = previousRoute?.settings.name ?? '';
}
}
}

// route_handle.dart
class RouteUtil {
var minePageHandler = Handler(handlerFunc: (BuildContext? context, Map<String, List<String>> parameters) {
return MinePage.fromArgs(arguments: getArguments(context));
});
}

// base_page.dart
abstract class BJHBasePage extends LifeCyclePage {
final Sting? fromRoutePath;
const BasePage({
Key? key,
this.fromRoutePath, // 基类增加 可空的fromRoutePath 参数
}) : super(key: key);


// mine_page.dart
class MinePage extends BasePage {
final String userId;
MinePage({
Key? key,
String? fromRoutePath,
required this.userId,
}) : super(key: key, fromRoutePath: fromRoutePath);

MinePage fromArgs({Key? key, required Map<String, dynamic> arguments}) {
return WriteInvoicePage(
fromRoutePath: arguments["fromRoutePath"],
userId: arguments["userId"] ?? "",
);
}

void goBack() {
if fromRoutePath == "ShareWebView" {
ShareWebViewRouteUtil.hide() // 全局webView视图 的关闭
} else {
SystemRouteUtil.pop();
}
}
}

End

路由规范(含点击属性规范)

路由规范

一、路由地址规范

1、路由/点击操作类型

序号 操作类型标识 操作类型描述 场景举例
1 openPage 页面跳转 发布内容按钮
2 popupWindow 弹出弹窗 查看物流按钮
3 popupToast 弹出Toast 催一催按钮

路由操作,常与按钮点击结合在一起,所以我们顺便约定【点击按钮的属性规范】。

2、点击按钮的属性与类规范

2.1、背景/场景示例

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
// 页面跳转
{
"action_type": "route",
"action_name": "发布内容",
"action_value": "appScheme://openPage?pageName=PublishPage&title=123"
}

// 弹出弹窗
{
"action_type": "popup",
"action_name": "查看物流",
"action_value": "appScheme://popupWindow?windowName=LookLogisticsWindow&uid=123&goodId=456
}

// 弹出Toast
{
"action_type": "toast",
"action_name": "催一催",
"action_value": "appScheme://popupToast?toastName=common&title=Error&message=something_wrong
}


// 其他(点击发起其他请求)
{
"action_type": "custom",
"action_name": "删除订单"
}


2.2、点击属性类规范

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// 基类
enum TapType {
route,
popup,
toast,
custom,
}

abstract class BaseTapModel {
final String? id; // 可用来作为埋点的id
final TapType? actionType;
final String? actionValue;
BaseTapModel({this.id, this.actionType, this.actionValue});
}

// 有 文字/图片
class OnTapModel extends BaseTapModel {
final String? actionName;
final String? actionImageUrl; // 用于支持图片
final String? selectedActionName;
final String? selectedActionImageUrl;

OnTapModel({
String? id,
TapType? actionType,
String? actionValue,
this.actionName,
this.actionImageUrl,
this.selectedActionName,
this.selectedActionImageUrl,
}) : assert(actionName != null || actionImageUrl != null),
super(
id: id,
actionType: actionType,
actionValue: actionValue,
);
}

// 有 badge
enum BadgeType {
dot, // 原点
number, // 数字
}

class BadgeButtonModel extends OnTapModel {
final BadgeType badgeType;
final int? badgeCount;

BadgeButtonModel({
String? id,
TapType? actionType,
String? actionValue,
String? actionName,
String? actionImageUrl,
String? selectedActionName,
String? selectedActionImageUrl,
this.badgeType = BadgeType.dot,
this.badgeCount,
}) : super(
id: id,
actionType: actionType,
actionValue: actionValue,
actionName: actionName,
actionImageUrl: actionImageUrl,
selectedActionName: selectedActionName,
selectedActionImageUrl: selectedActionImageUrl,
);
}

路由解析

三、WebUrl 的解析与构造

使用WebUrl解析的方法

1
2
3
4
5
6
7
8
// 解析方法如下:
/// 获取指定web地址的所有参数
static Map<String, dynamic>? getAllParamsFromWebUrl(String webUrl, {bool paramToObjectIfOK = false});

/// 将字符串value按需求转成 string 或者 object(如果可以转的情况下)
static dynamic getValueFromWebParamValueString(String value, {bool paramToObjectIfOK = false});

// 构造方法如下:

解析方法详细内容如下:

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import 'dart:convert';

import 'package:flutter/foundation.dart';

class WebUrlUtil {
/// 获取指定web地址的所有参数
///
/// [webUrl]:要获取参数的地址。
///
/// [paramToObjectIfOK]:一个布尔值,指示是否将参数值转换为对象(如果可能)。默认为false。
///
/// 返回包含地址参数的Map对象,其中参数名作为键,参数值作为值。
///
/// 例如:https://www.baidu.com/?a=1&b=2
/// 返回:{a: 1, b: 2}
static Map<String, dynamic>? getAllParamsFromWebUrl(
String webUrl, {
bool paramToObjectIfOK = false,
}) {
var paramStartIndex = webUrl.indexOf('?');
if (paramStartIndex == -1) {
return null;
}

Map<String, dynamic> paramMap = {};
var str = webUrl.substring(paramStartIndex + 1);
var strs = str.split('&');

for (var i = 0; i < strs.length; i++) {
var keyValueComponent = strs[i].split('=');
var key = keyValueComponent[0];
String value = keyValueComponent[1];
dynamic element = getValueFromWebParamValueString(
value,
paramToObjectIfOK: paramToObjectIfOK,
);

paramMap[key] = element;
}

return paramMap;
}

/// 将字符串value按需求转成 string 或者 object(如果可以转的情况下)
///
/// [value]:要处理的参数的值。
///
/// [paramToObjectIfOK]:一个布尔值,指示是否将参数值转换为对象(如果可能)。默认为false。
///
/// 返回参数值的处理结果。
static dynamic getValueFromWebParamValueString(
String value, {
required bool paramToObjectIfOK,
}) {
try {
value = Uri.decodeComponent(value);
} catch (error) {
// value = value;
debugPrint("不用解码");
}

if (paramToObjectIfOK != true) {
return value;
}

dynamic element; // 如果 json.decode 成功,返回类型会变化,所以需另声明变量
try {
element = json.decode(value);
} catch (error) {
element = value;
}
return element;
}
}

构造方法详细内容如下:

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
import 'dart:convert';

import 'package:flutter/foundation.dart';

class WebUrlUtil {
static String addH5CustomParams(
String newString,
Map<String, dynamic> h5Params,
) {
for (String h5ParamKey in h5Params.keys) {
var h5ParamValue = h5Params[h5ParamKey]; // 类型不一定是String
if (h5ParamValue == null) {
continue;
}
late String h5ParamEncodeValue;
if (h5ParamValue is String) {
// h5ParamEncodeValue = h5ParamValue;
h5ParamEncodeValue =
Uri.encodeComponent(h5ParamValue); // 要编码,否则即使是字符串,但是含中文时候,也会出错
} else {
// String h5ParamParamString = h5ParamValue.toString();
// String h5ParamParamString =
// FormatterUtil.convert(h5ParamValue, 0); // 使用此行来修复json字符串没有引号的问题
String h5ParamParamString = jsonEncode(
h5ParamValue); // 使用此行来修复json字符串没有引号的问题,且避免使用FormatterUtil.convert时候的换行问题
h5ParamEncodeValue = Uri.encodeComponent(
h5ParamParamString); // 要编码,否则url,在app中的webView无法识别(虽然在goole chrome或safari上可以识别)
}

String extraString = "$h5ParamKey=$h5ParamEncodeValue";
if (newString.contains('?')) {
newString += "&$extraString";
} else {
newString += "?$extraString";
}
}
return newString;
}
}

End

页面跳转相关(路由)

一、随时获取上一个页面的路径

背景:埋点为避免去从所有埋点里查找进入此页面前的上一个进入页面,希望直接知道此页面的上页面路径。

路由规范

字段 描述 示例
1 sourceFrom 从哪个环境过来
(如h5跳转到app)
appType/h5Type
2

1、无整屏显示的弹窗,即从任何弹窗进入记为从页面进入

二、优雅的自定义返回

背景:从页面进入弹窗(全局WebView)。弹窗和页面不在同一路由栈上,从而使得当页面返回的时候,需要自己判断返回到哪。

详见:优雅的自定义返回

三、一个不用重加载的WebView

背景:一个基于WebView的Cocos2d游戏,因为启动加载引擎会有3-5秒的加载时长。为了更好的游戏体验,希望启动后重新进入不用重新加载。

详见:一个不用重加载的WebView

四、游戏与app跳转交互优化调研

常见:web游戏,极小,游戏页独立存在

特殊:web游戏可以调到app页时候

关键策略:

1、调研游戏内容多网页共享方案
2、建立游戏测试页,提供后续方案验证基础
3、开发方案demo,进行游戏内容多网页实验
4、如果方案有效,后续进行含业务的完整方案梳理;如果无效则进行其他方案调研

参考文档:《WebView优化》中的【多页面网页内容共享】

调研过程:

1、完成Flutter游戏测试页的建立,并通过尝试共享网页视图或控制器两种方式,进行游戏内容在多页面的共享测试。得出共享时候视图树会重绘,即网页会重新加载,从而无法达到整个跳转过程在同一路由栈下的正常跳转交互。

2、完成实现原生游戏测试页,并在原生上通过共享单例视图方式,进行游戏内容在多页面的共享测试。仍会出现在共享时候视图重绘操作。

3、后续预想方案

方案①游戏页面与其他网页一样常规的正常重新开辟。

优点:能使得整个跳转过程在同一路由栈下的正常跳转交互,享有系统的各种进出交互。

优化点:页面开辟时候,游戏加载慢。

方案②游戏使用唯一独立窗口,形如支付宝蚂蚁森林。需要从游戏内去app的其他页面时候,使用先缩小游戏窗口,到一个悬浮按钮,再弹出目标页面。

存在现象:从目标页再进游戏的时候需要从悬浮按钮进入。

路由解析详见 路由规范(含点击属性规范)

使用WebUrl解析的方法

1
2
3
4
5
6
7
8
// 解析方法如下:
/// 获取指定web地址的所有参数
static Map<String, dynamic>? getAllParamsFromWebUrl(String webUrl, {bool paramToObjectIfOK = false});

/// 将字符串value按需求转成 string 或者 object(如果可以转的情况下)
static dynamic getValueFromWebParamValueString(String value, {bool paramToObjectIfOK = false});

// 构造方法如下:

解析方法详细内容如下:

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import 'dart:convert';

import 'package:flutter/foundation.dart';

class WebUrlUtil {
/// 获取指定web地址的所有参数
///
/// [webUrl]:要获取参数的地址。
///
/// [paramToObjectIfOK]:一个布尔值,指示是否将参数值转换为对象(如果可能)。默认为false。
///
/// 返回包含地址参数的Map对象,其中参数名作为键,参数值作为值。
///
/// 例如:https://www.baidu.com/?a=1&b=2
/// 返回:{a: 1, b: 2}
static Map<String, dynamic>? getAllParamsFromWebUrl(
String webUrl, {
bool paramToObjectIfOK = false,
}) {
var paramStartIndex = webUrl.indexOf('?');
if (paramStartIndex == -1) {
return null;
}

Map<String, dynamic> paramMap = {};
var str = webUrl.substring(paramStartIndex + 1);
var strs = str.split('&');

for (var i = 0; i < strs.length; i++) {
var keyValueComponent = strs[i].split('=');
var key = keyValueComponent[0];
String value = keyValueComponent[1];
dynamic element = getValueFromWebParamValueString(
value,
paramToObjectIfOK: paramToObjectIfOK,
);

paramMap[key] = element;
}

return paramMap;
}

/// 将字符串value按需求转成 string 或者 object(如果可以转的情况下)
///
/// [value]:要处理的参数的值。
///
/// [paramToObjectIfOK]:一个布尔值,指示是否将参数值转换为对象(如果可能)。默认为false。
///
/// 返回参数值的处理结果。
static dynamic getValueFromWebParamValueString(
String value, {
required bool paramToObjectIfOK,
}) {
try {
value = Uri.decodeComponent(value);
} catch (error) {
// value = value;
debugPrint("不用解码");
}

if (paramToObjectIfOK != true) {
return value;
}

dynamic element; // 如果 json.decode 成功,返回类型会变化,所以需另声明变量
try {
element = json.decode(value);
} catch (error) {
element = value;
}
return element;
}
}

构造方法详细内容如下:

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
import 'dart:convert';

import 'package:flutter/foundation.dart';

class WebUrlUtil {
static String addH5CustomParams(
String newString,
Map<String, dynamic> h5Params,
) {
for (String h5ParamKey in h5Params.keys) {
var h5ParamValue = h5Params[h5ParamKey]; // 类型不一定是String
if (h5ParamValue == null) {
continue;
}
late String h5ParamEncodeValue;
if (h5ParamValue is String) {
// h5ParamEncodeValue = h5ParamValue;
h5ParamEncodeValue =
Uri.encodeComponent(h5ParamValue); // 要编码,否则即使是字符串,但是含中文时候,也会出错
} else {
// String h5ParamParamString = h5ParamValue.toString();
// String h5ParamParamString =
// FormatterUtil.convert(h5ParamValue, 0); // 使用此行来修复json字符串没有引号的问题
String h5ParamParamString = jsonEncode(
h5ParamValue); // 使用此行来修复json字符串没有引号的问题,且避免使用FormatterUtil.convert时候的换行问题
h5ParamEncodeValue = Uri.encodeComponent(
h5ParamParamString); // 要编码,否则url,在app中的webView无法识别(虽然在goole chrome或safari上可以识别)
}

String extraString = "$h5ParamKey=$h5ParamEncodeValue";
if (newString.contains('?')) {
newString += "&$extraString";
} else {
newString += "?$extraString";
}
}
return newString;
}
}

End

详解MaterialApp

Flutter MaterialApp的initialRoute属性和home属性的区别

1、home 是应用程序默认的路由小部件,如果指定了 home,那么 route 中就不能包含 /,home 会取代 /。
2、除非指定了 initialRoute(会先执行home,再执行initialRoute,在initialRoute页返回会回到home页),否则应用程序会首先显示 home 对应的小部件,如果 initialRoute 不能正常显示,也会显示 home。
使用场景:
若APP启动时需要根据是否登录来决定首先显示的页面,可在路由监听中判断 token,若未登录,则 initialRoute 设置登录页对应的别名,则会打开登录页。

End

Flutter 最常出现的错误

典型错误一:无法掌握的Future

典型错误信息:NoSuchMethodError: The method 'markNeedsBuild' was called on null.

这个错误常出现在异步任务(Future)处理,比如某个页面请求一个网络API数据,根据数据刷新 Widget State。

异步任务结束在页面被pop之后,但没有检查State 是否还是 mounted,继续调用 setState 就会出现这个错误。

示例代码

一段很常见的获取网络数据的代码,调用 requestApi(),等待Future从中获取response,进而setState刷新 Widget:

1
2
3
4
5
6
7
8
9
10
class AWidgetState extends State<AWidget> {
// ...
var data;
void loadData() async {
var response = await requestApi(...);
setState((){
this.data = response.data;
})
}
}

原因分析

response 的获取为async-await异步任务,完全有可能在AWidgetStatedispose之后才等到返回,那时候和该State 绑定的 Element 已经不在了。故而在setState时需要容错。

解决办法: setState之前检查是否 mounted

1
2
3
4
5
6
7
8
9
10
11
12
class AWidgetState extends State {
// ...
var data;
void loadData() async {
var response = await requestApi(...);
if (mounted) {
setState((){
this.data = response.data;
})
}
}
}

这个mounted检查很重要,其实只要涉及到异步还有各种回调(callback),都不要忘了检查该值。

比如,在 FrameCallback里执行一个动画(AnimationController):

1
2
3
4
5
6
@override
void initState(){
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _animationController.forward();
});
}

AnimationController有可能随着 State 一起 dispose了,但是FrameCallback仍然会被执行,进而导致异常。

又比如,在动画监听的回调里搞点事:

1
2
3
4
5
6
7
8
9
@override
void initState(){
_animationController.animation.addListener(_handleAnimationTick);
}


void _handleAnimationTick() {
if (mounted) updateWidget(...);
}

同样的在_handleAnimationTick被回调前,State 也有可能已经被dispose了。

如果你还不理解为什么,请仔细回味一下Event loop 还有复习一下 Dart 的线程模型。

典型错误二:Navigator.of(context) 是个 null

典型错误信息:NoSuchMethodError: The method 'pop' was called on null.

常在 showDialog 后处理 dialog 的 pop() 出现。

示例代码

在某个方法里获取网络数据,为了更好的提示用户,会先弹一个 loading 窗,之后再根据数据执行别的操作…

1
2
3
4
5
6
7
8
9
10
11
12
13
// show loading dialog on request data
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) {
return Center(
child: CircularIndicator(),
);
},
);
var data = (await requestApi(...)).data;
// got it, pop dialog
Navigator.of(context).pop();

原因分析:

出错的原因在于—— Android 原生的返回键:虽然代码指定了barrierDismissible: false,用户不可以点半透明区域关闭弹窗,但当用户点击返回键时,Flutter 引擎代码会调用 NavigationChannel.popRoute(),最终这个 loading dialog 甚至包括页面也被关掉,进而导致Navigator.of(context)返回的是null,因为该context已经被unmount,从一个已经凋零的树叶上是找不到它的根的,于是错误出现。

另外,代码里的Navigator.of(context) 所用的context也不是很正确,它其实是属于showDialog调用者的而非 dialog 所有,理论上应该用builder里传过来的context,沿着错误的树干虽然也能找到根,但实际上不是那么回事,特别是当你的APP里有Navigator嵌套时更应该注意。

解决办法

首先,确保 Navigator.of(context)context 是 dialog 的context;其次,检查 null,以应对被手动关闭的情况。

showDialog 时传入 GlobalKey,通过 GlobalKey去获取正确的context

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
GlobalKey key = GlobalKey();


showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) {
return KeyedSubtree(
key: key,
child: Center(
child: CircularIndicator(),
)
);
},
);
var data = (await requestApi(...)).data;


if (key.currentContext != null) {
Navigator.of(key.currentContext)?.pop();
}

key.currentContextnull意为着该 dialog 已经被dispose,亦即已经从 WidgetTree 中unmount

其实,类似的XXX.of(context)方法在 Flutter 代码里很常见,比如 MediaQuery.of(context)Theme.of(context)DefaultTextStyle.of(context)DefaultAssetBundle.of(context)等等,都要注意传入的context是来自正确节点的,否则会有惊喜在等你。

写 Flutter 代码时,脑海里一定要对context的树干脉络有清晰的认知,如果你还不是很理解context,可以看看 《深入理解BuildContext》 - Vadaski。

典型错误三:ScrollController 里薛定谔的 position

在获取ScrollControllerpositionoffset,或者调用jumpTo()等方法时,常出现StateError错误。

错误信息:StateError Bad state: Too many elementsStateError Bad state: No element

示例代码

在某个按钮点击后,通过ScrollController 控制ListView滚动到开头:

1
2
3
4
5
final ScrollController _primaryScrollController = ScrollController();
// 回到开头
void _handleTap() {
if(_primaryScrollController.offset > 0) _primaryScrollController.jumpTo(0.0)
}

原因分析

先看ScrollController的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ScrollController extends ChangeNotifier {
//...
@protected
Iterable<ScrollPosition> get positions => _positions;
final List<ScrollPosition> _positions = <ScrollPosition>[];

double get offset => position.pixels;

ScrollPosition get position {
assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
assert(_positions.length == 1, 'ScrollController attached to multiple scroll views.');
return _positions.single;
}
//...
}

很明显,ScrollControlleroffest 是从 position 中获得,而position 则是来自变量 _positions

StateError错误,就是_positions.single 这一行抛出:

1
2
3
4
5
6
7
8
9
10
11
abstract class Iterable<E> {
//...
E get single {
Iterator<E> it = iterator;
if (!it.moveNext()) throw IterableElementError.noElement();
E result = it.current;
if (it.moveNext()) throw IterableElementError.tooMany();
return result;
}
//...
}

那么问题来了,这个_positions 为什么忽而一滴不剩,忽而却嫌它给的太多了呢?ˊ_>ˋ

还是要回到 ScrollController 的源码里找找。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ScrollController extends ChangeNotifier {
// ...
void attach(ScrollPosition position) {
assert(!_positions.contains(position));
_positions.add(position);
position.addListener(notifyListeners);
}


void detach(ScrollPosition position) {
assert(_positions.contains(position));
position.removeListener(notifyListeners);
_positions.remove(position);
}
}
  1. 为什么没有数据(No element): ScrollController还没有 attach 一个 position。原因有两个:一个可能是还没被 mount 到树上(没有被Scrollable使用到);另外一个就是已经被 detach了。

  2. 为什么多了(Too many elements): ScrollController还没来得及 detach旧的 position,就又attach了一个新的。原因多半是因为ScrollController的用法不对,同一时间被多个 Scrollable关注到了。

解决办法

针对 No element 错误,只需判断一下 _positions是不是空的就行了,即hasClients

1
2
3
4
5
final ScrollController _primaryScrollController = ScrollController();
// 回到开头
void _handleTap() {
if(_primaryScrollController.hasClients && _primaryScrollController.offset > 0) _primaryScrollController.jumpTo(0.0)
}

针对 Too many elements 错误,确保ScrollController只会被一个 Scrollable绑定,别让它劈腿了,且被正确 dispose()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class WidgetState extends State {
final ScrollController _primaryScrollController = ScrollController();


@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _primaryScrollController,
itemCount: _itemCount,
itemBuilder: _buildItem,
)
}


int get _itemCount => ...;
Widget _buildItem(context, index) => ...;


@override
void dispose() {
super.dispose();
_primaryScrollController.dispose();
}
}

典型错误四:四处碰壁 null

Dart 这个语言可静可动,类型系统也独树一帜。万物都可以赋值null,就导致写惯了 Java 代码的同志们常常因为bool int double这种看起来是”primitive”的类型被null附体而头晕。

典型错误信息:

  • Failed assertion: boolean expression must not be null
  • NoSuchMethodError: The method '>' was called on null.
  • NoSuchMethodError: The method '+' was called on null.
  • NoSuchMethodError: The method '*' was called on null.

示例代码

这种错误,较常发生在使用服务端返回的数据model时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class StyleItem {
final String name;
final int id;
final bool hasNew;


StyleItem.fromJson(Map<String, dynamic> json):
this.name = json['name'],
this.id = json['id'],
this.hasNew = json['has_new'];
}


StyleItem item = StyleItem.fromJson(jsonDecode(...));


Widget build(StyleItem item) {
if (item.hasNew && item.id > 0) {
return Text(item.name);
}
return SizedBox.shrink();
}

原因分析

StyleItem.fromJson() 对数据没有容错处理,应当认为 map 里的value都有可能是 null

解决办法:容错

1
2
3
4
5
6
7
8
9
10
11
class StyleItem {
final String name;
final int id;
final bool hasNew;


StyleItem.fromJson(Map<String, dynamic> json):
this.name = json['name'],
this.id = json['id'] ?? 0,
this.hasNew = json['has_new'] ?? false;
}

一定要习惯 Dart 的类型系统,什么都有可能是null,比如下面一段代码,你细品有几处可能报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Test {
double fraction(Rect boundsA, Rect boundsB) {
double areaA = boundsA.width * boundsA.height;
double areaB = boundsB.width * boundsB.height;
return areaA / areaB;
}

void requestData(params, void onDone(data)) {
_requestApi(params).then((response) => onDone(response.data));
}

Future<dynamic> _requestApi(params) => ...;
}

小提示,onDone()也可以是null >﹏<。

在和原生用 MethodChannel传数据时更要特别注意,小心驶得万年船。

典型错误五:泛型里的 dynamic 一点也不 dynamic

典型错误信息:

  • type 'List<dynamic>' is not a subtype of type 'List<int>'
  • type '_InternalLinkedHashMap<dynamic, dynamic>' is not a subtype of type 'Map<String, String>'

常发生在给某个List、Map 变量赋值时。

示例代码

这种错误,也较常发生在使用服务端返回的数据model时。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Model {
final List<int> ids;
final Map<String, String> ext;


Model.fromJson(Map<String, dynamic> json):
this.ids = json['ids'],
this.ext= json['ext'];
}


var json = jsonDecode("""{"ids": [1,2,3], "ext": {"key": "value"}}""");
Model m = Model.fromJson(json);

原因分析

jsonDecode()这个方法转换出来的map的泛型是Map<String, dynamic>,意为 value 可能是任何类型(dynamic),当 value 是容器类型时,它其实是List<dynamic>或者Map<dynamic, dynamic>等等。

而 Dart 的类型系统中,虽然dynamic可以代表所有类型,在赋值时,如果数据类型事实上匹配(运行时类型相等)是可以被自动转换,但泛型里 dynamic 是不可以自动转换的。可以认为 List<dynamic>List<int>是两种运行时类型。

解决办法:使用 List.from, Map.from

1
2
3
4
5
6
7
8
9
class Model {
final List<int> ids;
final Map<String, String> ext;


Model.fromJson(Map<String, dynamic> json):
this.ids = List.from(json['ids'] ?? const []),
this.ext= Map.from(json['ext'] ?? const {});
}

End

Flutter iOS 点击状态栏回到顶部

一、会回到顶部的原理

ios中一个常见的交互是:点击顶部栏时,自动将当前的滚动区滚到顶部。在flutter中,大部分时候这件事是“自然完成”的,但是也有时候会遇到这个行为失效的情况。要解决这个问题首先自然是要看这个feature是如何实现的。
其实大部分都是Scaffold里面干的事:
Scaffold里有这样一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
switch (themeData.platform) {
case TargetPlatform.iOS:
_addIfNonNull(
children,
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _handleStatusBarTap,
// iOS accessibility automatically adds scroll-to-top to the clock in the status bar
excludeFromSemantics: true,
),
_ScaffoldSlot.statusBar,
removeLeftPadding: false,
removeTopPadding: true,
removeRightPadding: false,
removeBottomPadding: true,
);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
break;
}

这里命名很舒服,可以直接看出来在干什么:如果是ios的话,那就给Scaffold加一个在状态栏上的点击区,点击的话就会触发一个函数,这个函数干的事情如下:

1
2
3
4
5
6
7
8
9
10
11
final ScrollController _primaryScrollController = ScrollController();

void _handleStatusBarTap() {
if (_primaryScrollController.hasClients) {
_primaryScrollController.animateTo(
0.0,
duration: const Duration(milliseconds: 300),
curve: Curves.linear, // TODO(ianh): Use a more appropriate curve.
);
}
}

也就是,Scaffold会提供一个默认的ScrollController,而点击顶部栏会使得这个controller滚到顶部,在ScrollView的build函数中则会取这个controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@override
Widget build(BuildContext context) {
final List<Widget> slivers = buildSlivers(context);
final AxisDirection axisDirection = getDirection(context);

// 注意,此处的primary不是传入的primary,
// primary = primary ?? controller == null && identical(scrollDirection, Axis.vertical)
final ScrollController scrollController = primary
? PrimaryScrollController.of(context)
: controller;
final Scrollable scrollable = Scrollable(
dragStartBehavior: dragStartBehavior,
axisDirection: axisDirection,
controller: scrollController,
physics: physics,
semanticChildCount: semanticChildCount,
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return buildViewport(context, offset, axisDirection, slivers);
},
);
return primary && scrollController != null
? PrimaryScrollController.none(child: scrollable)
: scrollable;
}

如果指定controller的话,就不会使用PrimaryScrollview,如果不指定的话,则在primary为true时使用PrimaryController,而默认情况下controller为null,primary为true,因此一个裸体的ListView是会相应屏幕点击的。
知道了原理,就很容易分析自己代码里出的问题是什么,常见的可能就是:
1、没加Scaffold,这个其实并不常见(自相矛盾草),不过可以检查一下,一般总是会有Scaffold的
2、真正常见的:指定了controller,如果自己创建了一个Controller丢给ScrollView,那必然是会失效的。但是使用controller又是一个很常见且重要的需求,怎么办呢?也很简单,就是不要自己创建新的ScrollController,而是直接取PrimaryScrollController.of(context)这个controller,对其进行自己要做的操作。
3、相对不太常见且需要分析具体代码的:多个Scaffold导致的冲突。
注意到其实flutter里的这个点击状态栏并不是真的点击了状态栏,而是点击了“Scaffold提供的位于状态栏的可点击区域”,也就是说,如果有多个Scaffold就会有多个这样的区域。实际情况是,只有最内部的Scaffold的状态栏会有响应,而如果ScrollView所处位置取到的和点击的Scaffold不一致,自然也就不会有滚动到顶部的feature

二、错误示例

今天遇到ios点击状态栏无法回到顶部(原理在文章后)的问题。研究后发现,Scaffold组件虽然会自带这个功能。但使用时候,必须遵循指定规则才行。

我们以点击 TapStatusNormalPage 上的状态栏返回顶部来举例。

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
// tap_status_normal_page.dart

import 'package:flutter/material.dart';

class TapStatusNormalPage extends StatefulWidget {
const TapStatusNormalPage({Key key}) : super(key: key);

@override
State<TapStatusNormalPage> createState() => _TapStatusNormalPageState();
}

class _TapStatusNormalPageState extends State<TapStatusNormalPage> {
@override
Widget build(BuildContext context) {
return Scaffold( // 注意这个页面已经包了 Scaffold 了
appBar: AppBar(
title: const Text('状态栏点击-Normal5'),
),
body: ListView.builder(
itemCount: 200,
itemBuilder: (context, index) {
return Text('$index');
},
),
);
}
}

错误一:如果Scaffold里面又套了一个Scaffold,那么这个回到顶部就会失效。

失效示例1:

1
2
3
4
5
6
7
8
9
10
import 'package:flutter/material.dart';

class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: TapStatusNormalPage(), // 失效原因:TapStatusNormalPage里已经有 Scaffold 了
);
}
}

这种情况,点击状态栏便不会回到顶部,我们需要保证的就是每个页面仅有一个Scaffold。

错误二:app的builder的Widget生成,如果child再多余包Scaffold会导致点击状态栏无法回到顶部

失效示例2:

虽然home里只有一个 Scaffold ,但app的builder的Widget生成,如果child再多余包Scaffold会导致点击状态栏无法回到顶部

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
return ScreenUtilInit(
designSize: const Size(375, 812),
builder: () => MaterialApp(
navigatorKey: navigatorKey,
title: 'wish',
home: TapStatusNormalPage(),
builder: EasyLoading.init(builder: (context, widget) {
return MediaQuery(
///设置文字大小不随系统设置改变
data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0),
// child: Scaffold( // 注意:app的builder的Widget生成,如果child再多余包Scaffold会导致点击状态栏无法回到顶部
// resizeToAvoidBottomInset: false,
// body: widget,
// ),
child: widget, // OK
);
}),
),
);

错误三:一说要滚动就自定义controller

点击页面上的某个按钮,让页面上的列表滚动到顶部(不需要自定义controller)。

1
2
3
4
5
6
7
8
PrimaryScrollController.of(context).jumpTo(0);


PrimaryScrollController.of(context).animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.linear,
);

如果还要监听滚动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@override
void didChangeDependencies() {
super.didChangeDependencies();

if (!_hasEverInitListener) {
PrimaryScrollController.of(context).addListener(_handleScrollViewEvent);
_hasEverInitListener = true;
}
}

@override
void deactivate() {
super.deactivate();
PrimaryScrollController.of(context).removeListener(_handleScrollViewEvent);
}


_handleScrollViewEvent() {
// 滚动距离
double offsetY = PrimaryScrollController.of(context).offset;


}

错误四:Another exception was thrown: ScrollController attached to multiple scroll views.

问题:

flutter_swiper:Another exception was thrown: ScrollController attached to multiple scroll views

翻译一下:引发了另一个异常:ScrollController连接到多个滚动视图。

原因:

Flutter Swiper是一个轮播图组件,内部包含一个Widget List,当这个Widget List数量大于1,就可能会有这种情况

解决方案:给Swiper加一个Key即可解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
return Container(
child: AspectRatio(
aspectRatio: 1.5 / 1, // 宽高比450/300
child: Swiper(
key: UniqueKey(), // 这个必须添加,代表唯一
itemBuilder: (BuildContext context, int index) {
return new Image.network(
imgList[index]['url'],
fit: BoxFit.fill,
);
},
itemCount: imgList.length,
pagination: new SwiperPagination(),
control: new SwiperControl(),
autoplay: true,
),
),
);

End

Github Push 失败问题

参考文章:

github push代码不成功

问题一:Support for password authentication was removed on August 13, 2021. Please use a personal access token instead.

image-20220423220008550

remote: Support for password authentication was removed on August 13, 2021. Please use a personal access token instead.

remote: Please see https://github.blog/2020-12-15-token-authentication-requirements-for-git-operations/ for more information.

意思就是你原先的密码凭证从2021年8月13日开始就不能用了,要使用个人访问令牌(personal access token),就是把你的密码替换成token。

解决:github push更新token验证方式。

1、Please use a personal access token instead.(密码方式不适用了,需改使用token方式登录)

1、登录github官网进入setting

img

2.页面拉到底 找到 Developer Setting

3.右侧菜单栏找到Personal access tokens 点击后 找到右上角的Generate new token

img

4、token的使用

在终端上cd到要项目中,然后执行git pull。

image-20220423223148829

此时弹出

Username for ‘https://github.com‘: 此处输入你github 用户名

Password for ‘https://用户名@github.com’:把复制的token粘贴到此处(这里原先是输入密码的现在改成token)回车就好了

2、Failed to connect to github.com 443(登录方式是token了,但连接失败。)

image-20220423221025009

image-20220423221809343

解决方式,终端输入如下命令:

git config –global http.proxy

3、remote: Write access to repository not granted.(connect上了,但其他操作失败,需指定token的权限)

image-20220423222009245

解决:

image-20220423222457565

创建的时候下面的权限和是否设置过期时间(我是吧所有权限都勾选了) 根据自己情况选择 最后创建完后 吧token复制下来

4、LibreSSL SSL_connect: SSL_ERROR_SYSCALL in connection to github.com:443

image-20220423224215251

fatal: unable to access ‘https://github.com/dvlproad/001-UIKit-CQDemo-Flutter.git/‘: LibreSSL SSL_connect: SSL_ERROR_SYSCALL in connection to github.com:443

image-20220423223640807

解决:终端输入

git config http.sslVerify “false”

git config –global http.sslVerify “false”

三、访问GitHub遇到SSL_ERROR_SYSCALL错误解决方法

其他参考文章:访问GitHub遇到SSL_ERROR_SYSCALL错误解决方法

检查1:电脑上是否有curl-openssl(一般都有)

openssl version

image-20220423232921517

如果没有,则安装curl-openssl。安装方法如下:

1
brew install curl-openssl

image-20220423233608415

2、安装xxx时候失败,Error: No such file or directory @ rb_sysopen

1、原因

原来是一个依赖包下载不成功(harfbuzz-3.1.1.arm64_monterey)

解决:使用brew单独安装即可,然后再就是再次下载之前的包

用brew单独下载依赖包,就避免了找不到依赖版本的错误

1
brew install zstd

image-20220423234308334

之后再返回继续执行之前的

1
brew install curl-openssl

image-20220423234350492