注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

Flutter 为什么没有一款好用的UI框架?

哈喽,我是老刘 前两天,系统给我推送了一个问题。 我理解提问者真正想问的是:有没有一个不用学习那么多UI组件和渲染知识,可以简单快速搭建UI的东西。 Flutter 包括原生开发,为什么需要考虑那么多细节,不能做的简单一些? 首先,我们需要明白Flutter...
继续阅读 »

哈喽,我是老刘

前两天,系统给我推送了一个问题。


image.png


我理解提问者真正想问的是:有没有一个不用学习那么多UI组件和渲染知识,可以简单快速搭建UI的东西。


Flutter 包括原生开发,为什么需要考虑那么多细节,不能做的简单一些?


首先,我们需要明白Flutter的定位。

Flutter不是一个简单的甜品,而是一个能支撑大型系统开发的工程级框架。

这种定位和原生框架的定位是相当的。

因此,它要求整个框架有足够的灵活性,能适用于尽可能多的场景。


image.png


那么,如何提供足够的灵活性呢?

答案是让整个框架尽可能多的细节是可控的。

这就需要把整个框架的功能拆分的更细,提供的配置项足够多。

然而,这样的缺点就是开发起来会比较麻烦,需要控制很多细节。

因此,我们可以看到Flutter的组件拆分的很细,甚至有类似Padding这样专门负责缩进的组件,而且每个组件都有很多的配置参数。


Flutter配合Material组件库本身本就非常优秀的UI框架


虽然Flutter的灵活性带来了开发上的复杂性,但Flutter配合Material组件库本身就是一个非常优秀的UI框架。


image.png


Material组件库提供了丰富的预设组件,这些组件遵循Material Design指南,可以帮助开发者快速搭建出既美观又符合设计规范的UI界面。

使用Material组件库,开发者可以不必从头开始设计每一个UI元素,而是可以直接使用现成的组件,如按钮、对话框、卡片等,这些组件都有良好的交互和动画效果。

此外,Material组件库还提供了主题支持,开发者可以通过简单的配置,快速应用统一的风格到整个应用中。

因此,虽然Flutter的灵活性可能让初学者感到有些复杂,但配合Material组件库,Flutter实际上提供了一个非常高效和优秀的UI开发体验。


大型项目的正确打开方式


即便是Material组件库,它的设计是需要考虑应对各种不同类型app开发的,但是针对一个具体的项目,我们大多数时候不需要这样高的灵活性。

所以,这种情况下直接用Flutter提供的组件效率会比较低。

解放方法就是针对特定的项目做组件封装。


以我目前维护的项目为例,我们项目中所有的对话框都是相同的偏绿色调,圆角半径20,按钮大小固定,标题、详情的字体、字号也固定。

简单来说,就是所有的UI细节都是固定的,只是不同的dialog需要填充的文字不同。


这时候,我们就会定义一个自己的Dialog组件,只需要使用者传入标题和内容,以及设置按钮的回调即可。

UI的其他地方也是如此,比如页面框架、在多个页面都能用到的用户卡片、商品卡片等等。


当你的整个App大部分都是基于这些自定义组件进行搭积木式的开发,那开发效率是不是比找一些通用的UI框架更高呢?


总结


总而言之,Flutter因为它的工程级框架定位需要提供高度的灵活性,而这往往会导致开发细节的复杂性。

但是,通过针对具体项目的组件封装,我们可以大大提高开发效率,同时保持UI的一致性和项目的特定需求。

所以,与其寻找一个通用的UI框架,不如根据项目的具体需求进行自定义组件的开发。


如果看到这里的同学有学习Flutter的兴趣,欢迎联系老刘,我们互相学习。

点击免费领老刘整理的《Flutter开发手册》,覆盖90%应用开发场景。

可以作为Flutter学习的知识地图。

覆盖90%开发场景的《Flutter开发手册》


作者:程序员老刘
来源:juejin.cn/post/7387001928209170447
收起阅读 »

Dart 脚本:flutter 应用一键打包+发布

引言 近期整理技术栈,将陆续总结分享一些项目实战中用到的实用工具。本篇分享开发流程中必不可缺的一个环节,提测。 闲聊一下 当然,对于打包,我们可以直接使用命令行直接打包例如: flutter build apk --verbose 只是,相比输入命令行,我更...
继续阅读 »

引言


近期整理技术栈,将陆续总结分享一些项目实战中用到的实用工具。本篇分享开发流程中必不可缺的一个环节,提测


闲聊一下


当然,对于打包,我们可以直接使用命令行直接打包例如:


flutter build apk --verbose

只是,相比输入命令行,我更倾向于一键操作,更倾向写一个打包脚本,我可以在脚本里编辑个性化操作,例如:瘦身、修改产物(apk、ipa)名称、指定打包成功后事务等等。


比如在项目里新建一个文件夹,如 script, 当需要打包发布时,右键 Run 就 Ok 了。


image.png


下面,小编整理了基础款 dart 脚本,用于 打包上传蒲公英。有需要的同学可自行添加个性化处理。


Android 打包脚本


该脚本用于 apk 打包,apk 以当前时间戳名称,打包成功后可选直接打开文件夹或发布蒲公英。


import 'dart:io';
import 'package:intl/intl.dart';
import 'package:yaml/yaml.dart' as yaml;
import 'pgy_tool.dart'; //蒲公英发布脚本,下面会给出

void main(List<String> args) async {
//是否上传蒲公英
bool uploadPGY = true;

// 获取项目根目录
final _projectPath = await Process.run(
'pwd',
[],
);
final projectPath = (_projectPath.stdout as String).replaceAll(
'\n',
'',
);
// 控制台打印项目目录
stdout.write('项目目录:$projectPath 开始编译\n');

final process = await Process.start(
'flutter',
[
'build',
'apk',
'--verbose',
],
workingDirectory: projectPath,
mode: ProcessStartMode.inheritStdio,
);
final buildResult = await process.exitCode;
if (buildResult != 0) {
stdout.write('打包失败,请查看日志');
return;
}
process.kill();

//开始重命名
final file = File('$projectPath/pubspec.yaml');
final fileContent = file.readAsStringSync();
final yamlMap = yaml.loadYaml(fileContent) as yaml.YamlMap;

//获取当前版本号
final version = (yamlMap['version'].toString()).replaceAll(
'+',
'_',
);
final appName = yamlMap['name'].toString();

// apk 的输出目录
final apkDirectory = '$projectPath/build/app/outputs/flutter-apk/';
const buildAppName = 'app-release.apk';
final timeStr = DateFormat('yyyyMMddHHmm').format(
DateTime.now(),
);

final resultNameList = [
appName,
version,
timeStr,
].where((element) => element.isNotEmpty).toList();

final resultAppName = '${resultNameList.join('_')}.apk';
final appPath = apkDirectory + resultAppName;

//重命名apk文件
final apkFile = File(apkDirectory + buildAppName);
await apkFile.rename(appPath);
stdout.write('apk 打包成功 >>>>> $appPath \n');

if (uploadPGY) {
// 上传蒲公英
final pgyPublisher = PGYTool(
apiKey: '蒲公英控制台内你的应用的apiKey',
buildType: 'android',
);
final uploadSuccess = await pgyPublisher.publish(appPath);
if (uploadSuccess) {
File(appPath).delete();
}
} else {
// 直接打开文件
await Process.run(
'open',
[apkDirectory],
);
}
}

Ipa 打包脚本


ipa 打包脚本和 apk 打包脚本类似,只是过程中多了一步操作,删除之前的构建文件,如下:


import 'dart:io';
import 'package:yaml/yaml.dart' as yaml;
import 'package:intl/intl.dart';

import 'pgy_tool.dart';

void main() async {
const originIpaName = '你的应用名称';
//是否上传蒲公英
bool uploadPGY = true;

// 获取项目根目录
final _projectPath = await Process.run(
'pwd',
[],
);
final projectPath = (_projectPath.stdout as String).replaceAll(
'\n',
'',
);
// 控制台打印项目目录
stdout.write('项目目录:$projectPath 开始编译\n');

// 编译目录
final buildPath = '$projectPath/build/ios';

// 切换到项目目录
Directory.current = projectPath;

// 删除之前的构建文件
if (Directory(buildPath).existsSync()) {
Directory(buildPath).deleteSync(
recursive: true,
);
}

final process = await Process.start(
'flutter',
[
'build',
'ipa',
'--target=$projectPath/lib/main.dart',
'--verbose',
],
workingDirectory: projectPath,
mode: ProcessStartMode.inheritStdio,
);

final buildResult = await process.exitCode;
if (buildResult != 0) {
stdout.write('ipa 编译失败,请查看日志');
return;
}

process.kill();
stdout.write('ipa 编译成功!\n');

//开始重命名
final file = File('$projectPath/pubspec.yaml');
final fileContent = file.readAsStringSync();
final yamlMap = yaml.loadYaml(fileContent) as yaml.YamlMap;

//获取当前版本号
final version = (yamlMap['version'].toString()).replaceAll(
'+',
'_',
);
final appName = yamlMap['name'].toString();

// ipa 的输出目录
final ipaDirectory = '$projectPath/build/ios/ipa/';
const buildAppName = '$originIpaName.ipa';
final timeStr = DateFormat('yyyyMMddHHmm').format(
DateTime.now(),
);

final resultNameList = [
appName,
version,
timeStr,
].where((element) => element.isNotEmpty).toList();

final resultAppName = '${resultNameList.join('_')}.ipa';
final appPath = ipaDirectory + resultAppName;

//重命名ipa文件
final ipaFile = File(ipaDirectory + buildAppName);
await ipaFile.rename(appPath);
stdout.write('ipa 打包成功 >>>>> $appPath \n');

if (uploadPGY) {
// 上传蒲公英
final pgyPublisher = PGYTool(
apiKey: '蒲公英控制台内你的应用的apiKey',
buildType: 'ios',
);
pgyPublisher.publish(appPath);
} else {
// 直接打开文件
await Process.run(
'open',
[ipaDirectory],
);
}
}

蒲公英发布脚本


上面打包脚本中上传到蒲公英都调用了这句代码:


// 上传蒲公英
final pgyPublisher = PGYTool(
apiKey: '蒲公英控制台内你的应用的apiKey',
buildType: 'ios',
);
pgyPublisher.publish(appPath);


  • appPath:就是打包成功的 apk/ipa 本地路径

  • buildType:分别对应两个值 android、ios

  • apiKey:蒲公英控制台内你的应用对应的apiKey,如下所示


image.png


PGYTool 对应的发布脚本如下:


import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:rxdart/rxdart.dart';

// 蒲公英工具类
class PGYTool {
final getTokenPath = 'https://www.pgyer.com/apiv2/app/getCOSToken';
final getAppInfoPath = 'https://www.pgyer.com/apiv2/app/buildInfo';
final String apiKey;
final String buildType; //android、ios

PGYTool({
required this.apiKey,
required this.buildType,
});

//发布应用
Future<bool> publish(String appFilePath) async {
final dio = new Dio();
stdout.write('开始获取蒲公英token');
final tokenResponse = await _getToken(dio);
if (tokenResponse == null) {
stdout.write('>>>>>> 获取token失败 \n');
return false;
}
stdout.write('>>>>>> 获取token成功 \n');
final endpoint = tokenResponse['data']['endpoint'] ?? '';
final params = tokenResponse['data']['params'] ?? {};
stdout.write('蒲公英上传地址:$endpoint\n');
Map<String, dynamic> map = {
...params,
};
map['file'] = await MultipartFile.fromFile(appFilePath);
final controller = StreamController<MapEntry<int, int>>();
controller.stream
.throttleTime(const Duration(seconds: 1), trailing: true)
.listen(
(event) => stdout.write(
'${event.key}/${event.value} ${(event.key.toDouble() / event.value.toDouble() * 100).toStringAsFixed(2)}% \n',
),
onDone: () {
controller.close();
},
onError: (e) {
controller.close();
},
);
final uploadRsp = await dio.post(
endpoint,
data: FormData.fromMap(map),
onSendProgress: (count, total) {
controller.sink.add(
MapEntry<int, int>(
count,
total,
),
);
},
);
await Future.delayed(const Duration(seconds: 1));
if (uploadRsp.statusCode != 204) {
stdout.write('>>>>> 蒲公英上传失败 \n');
return false;
}
stdout.write('>>>>> 蒲公英上传成功 \n');
await Future.delayed(const Duration(seconds: 3));
await _getAppInfo(dio, tokenResponse['data']['key']);
return true;
}

// 获取蒲公英token
Future<Map<String, dynamic>?> _getToken(Dio dio) async {
Response<Map<String, dynamic>>? tokenResponse;
try {
tokenResponse = await dio.post<Map<String, dynamic>>(
getTokenPath,
queryParameters: {
'_api_key': apiKey,
'buildType': buildType,
},
);
} catch (_) {
stdout.write('_getToken error : $_');
}
if (tokenResponse == null) return null;
final responseJson = tokenResponse.data ?? {};
final tokenCode = responseJson['code'] ?? 100;
if (tokenCode != 0) {
return null;
} else {
return responseJson;
}
}

// tokenKey 是获取token中的返回值Key
Future<void> _getAppInfo(Dio dio, String tokenKey, {int retryCount = 3}) async {
final response = await dio.get<Map<String, dynamic>>(
getAppInfoPath,
queryParameters: {
'_api_key': apiKey,
'buildKey': tokenKey,
},
).then((value) {
return value.data ?? {};
});
final responseCode = response['code'];
if (responseCode == 1247 && retryCount > 0) {
//应用正在发布中,间隔 3 秒重新获取
stdout.write('>>>>> 应用正在发布中,间隔 3 秒重新获取发布信息\n');
await Future.delayed(const Duration(seconds: 3));
return _getAppInfo(dio, tokenKey, retryCount: retryCount - 1);
}
final appName = response['data']['buildName'];
final appVersion = response['data']['buildVersion'];
final appUrl = response['data']['buildShortcutUrl'];
final updateTime = response['data']['buildUpdated'];
if (appName != null) {
stdout.write('$appName 版本更新($appVersion)\n');
stdout.write('下载地址:https://www.pgyer.com/$appUrl\n');
stdout.write('更新时间:$updateTime\n');
}
}
}

运行发布脚本后,控制台会将应用的上传成功后的下载地址打印出来。


作者:李小轰_Rex
来源:juejin.cn/post/7304538454875586587
收起阅读 »

在微信小程序里运行完整的 Flutter,我们是怎么做到的?

背景 小程序是一种全新的业务形态,特别是微信小程序,既结合了 Web 动态化特性,又拥有 Native 丰富的设备能力支持。 在微信这个宿主上,小程序不仅有稳定的分发渠道,更拥有完善的生命周期、数据、AI 能力支持。 在该微信上开发小程序,一般使用以下两种方法...
继续阅读 »

背景


小程序是一种全新的业务形态,特别是微信小程序,既结合了 Web 动态化特性,又拥有 Native 丰富的设备能力支持。


在微信这个宿主上,小程序不仅有稳定的分发渠道,更拥有完善的生命周期、数据、AI 能力支持。


在该微信上开发小程序,一般使用以下两种方法:



  • JavaScript + WXML + WCSS

  • Taro + React + JavaScript


本文要介绍的是使用 Flutter Framework 开发小程序的方法,以及该方法背后的技术原理。


技术挑战


尽管 Flutter 官方已经提供 Flutter Web 实现,Flutter Web 本身就是基于 dart2js 运行的,微信小程序可以运行 JavaScript,在原理上跑起 Flutter Web 是没有问题的。


但仍然存在以下技术挑战:



  • 微信小程序没有 W3C 标准的 JavaScript 对象,Flutter Web 不能直接运行。

  • 微信小程序也没有 DOM 实现,Flutter Web HTML Renderer 不能直接渲染。

  • 微信小程序对包大小的限制十分严格,主包不能超过 2M,而 Flutter Web 所编译的 main.dart.js 初始体积就有 1.3 M,必须有合理的分包机制才能上传。


我们在 MPFlutter 1.x 版本中,针对上述问题已有一定的探索,1.x 版本的解决方法如下:



  • 使用微信开源的 kbone 库,模拟 W3C 实现,并通过模拟的 DOM 对象渲染出符合 WXML 要求的视图树。

  • 通过 Shadow Element Tree 的方式,使用 JSON 在 Dart 与 JavaScript 上下文同步视图树。

  • Fork Flutter Framework,并对其进行外科手术式的裁剪,使 main.dart.js 初始体积降低到 600K。


MPFlutter 1.x 方案已经良好的运行了两年,也收到了开发者非常多的反馈,开发者常诟病于裁剪后的 Flutter Framework 不兼容 Flutter 生态上的插件,同时 material 库也无法使用,需要从头开始编写 UI。


在 MPFlutter 2.0 版本,我们重新思考在小程序上运行 Flutter 的最佳方式,并在最终使用 CanvasKit Renderer 解决以上全部问题。


技术方案


Summary


通过裁剪 Skia 生成符合微信小程序分包要求的 CanvasKit,使用 Flutter Web + W3C BOM + WebGL Canvas 跑通渲染流程。


技术选型


在介绍技术选型前,需要先介绍 Flutter Web 的两种 Renderer。


HTML Renderer


原理是 Flutter Framework 通过 dart:js 库调用 Document 对象,并基于此将各种 RenderObject 转换为对应的 Element + CSS 添加到 DOM 树中。


该方案优点在于兼容性很好,几乎没有额外的依赖;缺点是性能不佳,并且渲染内容一致性难以与 Native Flutter 对齐。


CanvasKit Renderer


原理是通过 WebGL + Skia 渲染界面,该渲染方式与 Native Flutter 是完全一致的。


该方案优点在于渲染性能非常好,一致性与 Native Flutter 几乎没有差别;缺点是内存占用大,且需要从远端加载字体。


MPFlutter 2.0 选型


我们在 1.x 版本中用的是 HTML Renderer,通过 kbone 运行的 DOM 模拟层存在很多的问题,最令人诟病的是数据更新后界面刷新慢。当然问题的并不在于 kbone,而是 MPFlutter 1.x 本身对于 Element Tree 的序列化、反序列化的处理存在天然的缺陷,尽管已经通过 Dirty 和 Diff 等手段优化。


在 2.x 版本中,我们直接抛弃 HTML Renderer 的想法,使用 CanvasKit Renderer。


使用 CanvasKit Renderer 有这几个大前提:



  • 微信小程序已支持 WebAssembly 并支持 Brotli 压缩;

  • 微信小程序 Canvas 的性能相比最初的版本有质的提升,并支持 WebGL;

  • 微信小程序全部分包限制放宽到 20M,足够使用。


Skia 裁剪


Skia 是 Google 开源的 2D 渲染库,凭借良好的跨设备能力,优秀的性能表现,在 Google 多个产品中被使用,包括 Chrome / Flutter / Android / Fuchsia 都有 Skia 的身影。


Skia 屏蔽了不同设备、平台的具体实现,对外统一以标准的 RenderObject、RenderCommand 开放。


Skia 其中一个 Render Target 是 WebGL,也就是 CanvasKit。


然而 Flutter Web 默认使用的 CanvasKit 足有 6M 之大,即使使用 Brotli 压缩后仍然不符合小程序分包要求。


我们可以通过指定编译选项的方式裁剪 CanvasKit 尺寸,以下是 MPFlutter 使用的 build 配置:


./modules/canvaskit/compile.sh release no_skottie no_sksl_trace no_alias_font no_effects_deserialization no_encode_jpeg no_encode_png no_encode_webp legacy_draw_vertices no_embedded_font no_woff2

从配置可见,我们去掉了 skottie、image encoder、内置字体等不必要的功能,这些功能我们可以使用微信小程序 API 补充回来。


Brotli 压缩后的 wasm 文件刚好符合 2M 分包要求。


CanvasKit 加载


Skia 构建完成后,会得到两个产物,canvaskit.wasmcanvaskit.js


canvaskit.js 暴露了 wasm 中的各个 c++ 方法调用,同时也提供加载 wasm 的脚手架。


但是 canvaskit.js 的实现默认是 Web 的,我们需要将其中的 fetch 以及 WebAssembly 替换为微信小程序对应的实现。


这里提供一个使用 Skia 绘制红色矩形的微信小程序工程,有兴趣的同学可以下载到本地研究。


mpflutter.feishu.cn/wiki/LWhrw3…


Flutter Web 在微信中运行


要使 Flutter Web 在微信中运行,最大难点在于 Flutter Web 要求的 Web API 如何补充完整。


特别是 Document 、Window、Navigator 这些类,这些类我已经在 GitHub 上开源了,感兴趣的可以逐个文件阅读。


github.com/mpflutter/m…


这里举一个 window 的文件节选段落讲解:


export class FlutterMiniProgramMockWindow {
// screens
get devicePixelRatio() {
return wxSystemInfo.pixelRatio;
}

get innerWidth() {
return wxSystemInfo.windowWidth;
}

get innerHeight() {
return wxSystemInfo.windowHeight;
}

// webs
navigator = {
appVersion: "",
platform: "",
userAgent: "",
vendor: "",
language: "zh",
};

// 还有更多。。。
}

Flutter Web 在运行过程中,会通过 window.innerWidth / window.innerHeight 获取当前窗口宽高,以便下一步创建合适大小的画布用于渲染。


在微信小程序中,我们需要使用 wx.getSystemInfoSync() 获取对应宽高,并在 MockWindow 中返回给 Flutter。


关于 BOM 的文件,就不详细展开,都是一些胶水代码。


而 Flutter 的 main.dart.js 也需要有一些改造才可以跑在小程序上,主要的改造是通过 export main.dart.js 中的 main 函数,使其适配 CommonJS 可暴露给 Page 调用。


字体的加载


CanvasKit 最大的问题在于字体加载,目前来看是无法复用系统本身的字体的。


我们的做法是通过裁剪 NotoSansSC 字体,只包含常用的 9000+ 汉字,内置于小程序包中优先加载它。


这样有一个好处,小程序不需要强制从 gstatic 下载字体,省流省加载时间。


后续,我们还会研究通过 Canvas 2D 的方式,从本地加载字体。


分包


关于分包,其实是最好做的,因为 Flutter Web 本身就有 defered load 编译能力。


开发者可以轻松地将 main.dart.js 切分成若干个 JS 文件,我们做的就是在 Flutter Web 编译完成后,智能地将这些 JS 文件分配到不同的分包就好了。


资源分包也同理,资源通过 brotli 压缩也可以减少包体积。


总结


整整一套下来,Flutter 已经可以在微信小程序里跑起来了,我们来总结一下做了什么?


我们通过裁剪 Skia 使得 CanvasKit 可以很好地跑在小程序上,通过 BOM 兼容的方法,使得 Flutter Web 可以在微信小程序中找到对应实现,通过字体内置、智能分包的方式很好地解决了微信包体积限制。


该方案目前已经完全跑通,并已可用,同学们可以在 v2.mpflutter.com 文档站了解到更多用法。


如果对方案有任何疑问,也欢迎添加微信交流,感谢大家的关注。


作者:PonyCui
来源:juejin.cn/post/7324923422295670834
收起阅读 »

轻量桌面应用新星:Electrico,能否颠覆Electron的地位?

在桌面应用开发的世界里,Electron曾经是一位风云人物。它让开发者可以用熟悉的Web技术构建跨平台应用,但它的重量级体积和系统资源的高消耗一直让人头疼。现在,一个新工具悄然登场,试图解决这些问题——Electrico,一个轻量版的桌面应用开发框架。 10...
继续阅读 »

在桌面应用开发的世界里,Electron曾经是一位风云人物。它让开发者可以用熟悉的Web技术构建跨平台应用,但它的重量级体积和系统资源的高消耗一直让人头疼。现在,一个新工具悄然登场,试图解决这些问题——Electrico,一个轻量版的桌面应用开发框架。



10MB取代数百MB,你不心动?


你有没有想过,是否能用更轻量的方式开发出与Electron相同功能的桌面应用?毕竟,虽然Electron确实强大,但它那几百MB的安装包和资源消耗对许多小型项目来说太过头了。如果你对这些问题感到无奈,Electrico或许是你一直在等待的解决方案。它的安装包仅仅10MB左右,去掉了庞大的Node.js和Chromium,但依然能给你带来熟悉的开发体验。


什么是Electrico?


Electrico是一个基于Rust的轻量化桌面应用开发框架,完全省去了Node.js和Chrome内核的依赖。Rust编写的Wry库替代了Electron的核心,利用系统自带的WebView组件,保持跨平台兼容性。同时,Electrico还能与操作系统直接交互,提升了运行效率。未来可期的好处是 API 完全贴近 electron,这可能对原 electron 开发者会比较友好。


这一切听起来可能有点像技术术语,但如果你想象一下:Electron是一个庞大的精装房,而Electrico则是一间简单却功能齐全的小公寓。虽然面积小,但该有的功能一点也不少。


三大亮点:为什么Electrico值得关注?




  1. 1. 极致轻量化:从几百MB到10MB的飞跃 Electron的打包体积问题一直是开发者头疼的地方,尤其是当你只需要开发一个简单的工具时,最终却要交付一个几百MB的安装包。而Electrico的体积仅10MB左右,这样极致的轻量化使得它尤其适合资源有限的应用场景,如内部工具或简单的桌面应用。

  2. 2. 性能提升:用Rust打造高效体验 Rust作为新兴的系统编程语言,因其安全性和性能闻名。Electrico选择了Rust作为核心,这不仅使得应用更加高效,还让内存管理更加安全。尤其是在需要高性能、低延迟的场景下,Electrico展现了其独特的优势。与Electron依赖的V8引擎和Chromium相比,Electrico能够更直接地与系统交互,减少了许多不必要的资源消耗。




  1. 1. 兼容性好:熟悉的开发体验 开发者的最大顾虑之一,通常是新工具是否需要重新学习。而Electrico则保留了许多Electron的API设计,比如窗口管理和文件系统访问等。这意味着,习惯Electron的开发者几乎不需要额外学习,就能快速上手。同时,Electrico支持现代浏览器的开发者工具,前后端的调试体验也非常流畅。


实际开发中的表现


为了帮助开发者更快上手,Electrico提供了一个开源示例项目,让你可以直接体验它的运行效果。这个项目采用了Codex,一个轻量级的笔记应用。通过简单的配置和打包,你可以将Codex运行在Electrico上,而最终生成的应用包体积比起Electron版本要小得多。虽然目前Electrico只实现了部分Electron API,但它已经足够应对大多数日常应用场景。



比如,如果你开发的是一个简单的笔记工具、待办事项管理应用,或是一个内部的管理面板,Electrico都能帮你快速构建出符合需求的桌面应用。没有繁琐的依赖管理,也没有巨大的安装包拖慢你的用户体验。



对比Electron:未来的发展趋势


不得不承认,Electron凭借其强大的生态和广泛的支持,依然在桌面应用开发领域占有重要地位。尤其是对于那些需要集成大量第三方库、复杂业务逻辑的应用,Electron仍然是首选。但Electrico的出现,标志着开发者可以在不同场景下有更多选择。


对于那些不需要复杂依赖、注重性能和体积的小型应用,Electrico无疑是一个更现代、更轻便的选择。它展示了桌面应用开发的新趋势——极致轻量化和性能至上,正是未来开发工具追求的方向。


绝对值得一试的新选择


如果你正在寻找一种比Electron更轻量、更高效的解决方案,Electrico无疑值得一试。特别是当你对现有工具的体积和性能表现不满时,Electrico能够带来焕然一新的体验。最重要的是,它的学习成本几乎为零,你可以很快将现有的Electron项目迁移到Electrico上,享受同样的开发便利,却不再担心过大的应用包和资源消耗。


试想一下,你的下一个桌面应用项目,是否可以用更轻、更快、更高效的Electrico来实现?


作者:老码小张
来源:juejin.cn/post/7415663559310606363
收起阅读 »

Flutter UI组件库(JUI)

Flutter UI组件库 (JUI) 介绍 您是否正在寻找一种方法来简化Flutter开发过程,并创建美观、一致的用户界面?您的搜索到此为止!我们的Flutter UI组件库(JUI)提供了广泛的预构建、可自定义组件,帮助您快速构建令人惊叹的应用程序。 快速...
继续阅读 »

Flutter UI组件库 (JUI) 介绍


您是否正在寻找一种方法来简化Flutter开发过程,并创建美观、一致的用户界面?您的搜索到此为止!我们的Flutter UI组件库(JUI)提供了广泛的预构建、可自定义组件,帮助您快速构建令人惊叹的应用程序。


快速链接



为什么选择我们的UI组件库?



  1. 丰富的组件集合:从基本按钮到复杂表单,我们的库涵盖了所有UI需求。

  2. 可定制且灵活:每个组件都高度可定制,让您保持应用程序的独特外观和感觉。

  3. 易于使用:清晰的文档和直观的API,让您轻松将我们的组件集成到您的项目中。

  4. 节省时间:减少UI实现的时间,将更多精力放在应用程序的核心功能上。

  5. 一致的设计:通过我们精心设计的组件,确保整个应用程序的外观协调一致。


组件详解


我们的库包含多种组件,每个组件都经过精心设计,以满足不同的UI需求。以下是对各类组件的详细介绍:


1. 通用组件


1.1 JuiButton(多样化按钮)

按钮示例


JuiButton提供了多种样式和尺寸的按钮选择:



  • 多种颜色类型:包括蓝色、灰色、红色等,适应不同的UI主题。

  • 可选尺寸:从小型到大型,满足各种布局需求。

  • 自定义功能:支持添加图标、调整字体大小、设置点击事件等。


1.2 JuiDashedBorder(虚线边框)

虚线边框示例


JuiDashedBorder为容器提供了引人注目的虚线边框设计:



  • 可自定义虚线样式:调整虚线的宽度、高度、间距等。

  • 支持圆角:可设置边框的圆角半径,增加设计的灵活性。

  • 互动功能:可添加点击事件,增强用户交互体验。


2. 数据展示


2.1 JuiExpandableText(可展开文本)

可展开文本示例


JuiExpandableText适用于管理长文本内容:



  • 自动折叠:超过指定行数的文本会自动折叠。

  • 展开/收起功能:用户可以通过点击展开或收起全文。

  • 自定义样式:支持设置文本样式、展开/收起按钮样式等。


2.2 JuiHighlightedText(高亮文本)

高亮文本示例


JuiHighlightedText用于在文本中突出显示特定内容:



  • 灵活的高亮方式:支持多个高亮词,每个词可有不同的样式。

  • 可点击功能:高亮部分可设置点击事件,增加交互性。

  • 样式自定义:可单独设置普通文本和高亮文本的样式。


2.3 JuiTag(可自定义标签)

标签示例


JuiTag提供了丰富的标签设计选项:



  • 多种颜色和形状:包括圆角矩形、圆形等,颜色可自定义。

  • 支持图标:可在标签中添加图标,增强视觉效果。

  • 大小可调:适应不同的布局需求。


2.4 JuiNoContent(空状态页面)

空状态页面示例


JuiNoContent用于优雅地展示无内容状态:



  • 预设样式:提供多种常见的空状态设计。

  • 自定义能力:支持自定义图片、文字和布局。

  • 响应式设计:自适应不同屏幕尺寸。


3. 数据录入


3.1 JuiCheckBox(复选框)

复选框示例


JuiCheckBox提供了灵活的多选功能:



  • 多种样式:支持方形和圆形两种基本样式。

  • 状态管理:轻松处理选中、未选中和禁用状态。

  • 自定义外观:可调整大小、颜色等视觉属性。


3.2 JuiSelectPicker(选择器)

选择器示例1
选择器示例2
选择器示例3


JuiSelectPicker提供了多种类型的选择器:



  • 滚轮选择器:适合选择日期、时间等连续数据。

  • 列表选择器:适用于长列表项的选择。

  • 操作选择器:类似于底部弹出的操作表,适合少量选项的快速选择。

  • 支持单选和多选:灵活满足不同的选择需求。

  • 自定义选项样式:可自定义选项的外观和布局。


3.3 CustomTimePicker(时间选择器)

时间选择器示例1
时间选择器示例2
时间选择器示例3
时间选择器示例4


CustomTimePicker提供了全面的时间选择功能:



  • 多种时间格式:支持年月日、年月、年月日时分等多种格式。

  • 范围选择:支持选择时间范围。

  • 灵活配置:可设置最小和最大可选时间。

  • 自定义外观:可调整选择器的样式以匹配您的应用主题。


4. 反馈


4.1 JuiDialog(对话框)

对话框示例1
对话框示例2
对话框示例3


JuiDialog提供了丰富的对话框选项:



  • 标准对话框:用于显示信息和确认操作。

  • 输入对话框:允许用户在对话框中输入文本。

  • 自定义对话框:支持完全自定义对话框内容。

  • 灵活的按钮配置:可自定义确认和取消按钮的文本和行为。

  • 样式定制:可调整对话框的宽度、标题样式等。


5. 表单


表单示例


我们的表单组件集提供了全面的解决方案:


5.1 JuiCustomItem(自定义表单项)


  • 允许完全自定义表单项的内容和布局。


5.2 JuiTextDetailItem(文本详情项)


  • 用于展示只读的文本信息,适合详情页面。


5.3 JuiTapItem(可点击项)


  • 创建可点击的表单项,通常用于导航或触发操作。


5.4 JuiRangeItem(范围选择项)


  • 允许用户输入或选择一个数值范围。


5.5 JuiTextInputItem(文本输入项)


  • 提供各种文本输入选项,支持单行、多行、数字等输入类型。


所有表单项都支持:



  • 必填标记

  • 禁用状态

  • 自定义样式

  • 错误提示

  • 辅助说明文本


快速开始


集成我们的组件非常简单。首先,在您的pubspec.yaml文件中添加依赖:


dependencies:
jui: ^latest_version

然后,在您的代码中导入并使用组件。例如:


import 'package:jui/jui.dart';

// 在您的widget构建方法中
JuiButton(
colorType: JuiButtonColorType.blue,
sizeType: JuiButtonSizeType.large,
text: "开始使用",
onTap: () {
// 您的操作代码
},
)

文档


我们为每个组件提供全面的文档,包括:



  • 详细的参数描述

  • 代码示例

  • 使用最佳实践


我们的在线文档始终保持最新,您可以在这里访问:http://www.yuque.com/jui_flutter…


立即开始构建更好的UI!


不要让UI开发拖慢您的脚步。使用我们的Flutter UI组件库,您可以比以往更快地创建专业外观的应用程序。在您的下一个项目中尝试一下,体验不同!


准备好提升您的Flutter开发了吗?今天就开始使用我们的UI组件库吧!




如果您有任何问题或建议,欢迎在我们的 GitHub 仓库 上提出 issue 或贡献代码。我们期待您的反馈,共同改进这个组件库!


作者:有趣的杰克
来源:juejin.cn/post/7425814107444740150
收起阅读 »

花式封装:Kotlin+协程+Flow+Retrofit+OkHttp +Repository,倾囊相授,彻底减少模版代码进阶之路

前言 :众里寻它千百度, 蓦然回首,此种代码却在灯火阑珊处。注解处理器在架构,框架中实战应用:MVVM中数据源提供Repository类的自动生成一、前言本文介绍思路:本文重点介绍思路:四种方式花式解决Repository中模版式的代码,逐级递增1.1 :涉及...
继续阅读 »

e1ff3706ea196f758818da129df6de53.png

前言 :众里寻它千百度, 蓦然回首,此种代码却在灯火阑珊处。

注解处理器在架构,框架中实战应用:MVVM中数据源提供Repository类的自动生成

一、前言

  1. 本文介绍思路:
    本文重点介绍思路:四种方式花式解决Repository中模版式的代码,逐级递增
    1.1 :涉及到Kotlin协程Flow、viewModel、Retrofit、Okhttp相关用法
    1.2 :涉及到注解反射泛型注解处理器相关用法
    1.3 :涉及到动态代理kotlinsuspend方法反射调用及反射中异常处理
    1.4 :本示例4个项目如图:

    380Xt8NSYZ.jpg

  2. 网络框架搭建的封装,到目前为止最为流行又很优雅的的是 Kotlin+协程+Flow+Retrofit+OkHttp+Repository
  3. 先来看看中间各个类的职责: whiteboard_exported_image.png
  4. 从上图可以看出单一职责:

    NetApi: 负责网络接口配置,包括 请求地址,请求头,请求方式,参数等等所有配置

    Flow+Retrofit+Okhttp: 联合起来负责把 NetApi 中的各种配置组装成网络请求行为,并且通过Flow 组装成流,通过它可以控制该行为的异步方式,异步开始结束等等一系列的流行为。

    Repository: 负责 Flow+Retrofit+Okhttp 请求结果的数据流,进行加工处理成我们想要的数据,大多数不需要处理的,可以直接给到 ViewModel

    ViewModel: 负责调用 Repository,拿到想要的数据然后提供给UI方展示使用或者相关使用

    也可以看到 它的 持有链 从右向左 一条线性持有:ViewModel 持有 RepositoryRepository持有 Flow+Retrofit+Okhttp ,Flow+Retrofit+Okhttp 持有 NetApi

  5. 最终我们可以得到:
    5.1. 网络请求行为 会根据 NetApi 写出模板式的代码,这块解决模版式的代码在 Retrofit 中它通过动态代理,把所有模版式的代码统一成了一个
    5.2. 同理:Repository 也是根据 NetApi 配置的接口,写成模版式的代码转换成流

二、花式封装(一)

  1. NetApi 的配置:
interface NetApi {

// 示例get 请求
@GET("https://www.wanandroid.com/article/list/0/json")
suspend fun getHomeList(): CommonResult

// 示例get 请求2
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList(@Path("path") page: Int): CommonResult

@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList(@Path("path") page: Int, @Path("path") a: Int): CommonResult

@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList(@Path("path") page: Int, @Path("path") f: Float): CommonResult

// 示例get 请求2
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList2222(@Path("path") page: Int): CommonResult

@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList3333(@Path("path") page: Int): CommonResult

@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList5555(@Path("path") page: Int, @Query("d") ss: String, @HeaderMap map: Map): CommonResult

@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList6666(
@Path("path") page: Int,
@Query("d") float: Float,
@Query("d") long: Long,
@Query("d") double: Double,
@Query("d") byte: Byte,
@Query("d") short: Short,
@Query("d") char: Char,
@Query("d") boolean: Boolean,
@Query("d") string: String,
@Body body: RequestBodyWrapper
): CommonResult

//示例post 请求
@FormUrlEncoded
@POST("https://www.wanandroid.com/user/register")
suspend fun register(
@Field("username") username: String,
@Field("password") password: String,
@Field("repassword") repassword: String
): String
/************************* 以下只 示例写法,接口调不通,因为找不到那么多 公开接口 全是 Retrofit的用法 来测试 *****************************************************/


// @FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded") //todo 固定 header
@POST("https://xxxxxxx")
suspend fun post1(@Body body: RequestBody): String

// @FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded")
@POST("https://xxxxxxx22222")
suspend fun post12(@Body body: RequestBody, @HeaderMap map: Map): String //todo HeaderMap 多个请求头部自己填写

suspend fun post1222(@Body body: RequestBody, @HeaderMap map: Map): String //todo HeaderMap 多个请求头部自己填写
}

2. NetRepository 中是 根据 NetApi 写出下面类似的全模版式的代码:都是返回 Flow 流

class NetRepository private constructor() {
val service by lazy { RetrofitUtils.instance.create(NetApi::class.java) }

companion object {
val instance by lazy { NetRepository() }
}

// 示例get 请求
fun getHomeList() = flow { emit(service.getHomeList()) }

// 示例get 请求2
fun getHomeList(page: Int) = flow { emit(service.getHomeList(page)) }

fun getHomeList(page: Int, a: Int) = flow { emit(service.getHomeList(page, a)) }

fun getHomeList(page: Int, f: Float) = flow { emit(service.getHomeList(page, f)) }

// 示例get 请求2
fun getHomeList2222(page: Int) = flow { emit(service.getHomeList2222(page)) }

fun getHomeList3333(page: Int) = flow { emit(service.getHomeList3333(page)) }

fun getHomeList5555(page: Int, ss: String, map: Map<String, String>) = flow { emit(service.getHomeList5555(page, ss, map)) }

fun getHomeList6666(
page: Int, float: Float, long: Long, double: Double, byte: Byte,
short: Short, char: Char, boolean: Boolean, string: String, body: RequestBodyWrapper
)
= flow {
emit(service.getHomeList6666(page, float, long, double, byte, short, char, boolean, string, body))
}

fun register(username: String, password: String, repassword: String) = flow { emit(service.register(username, password, repassword)) }

//
// /************************* 以下只 示例写法,接口调不通,因为找不到那么多 公开接口 全是 Retrofit的用法 来测试 *****************************************************/
//
//
fun post1(body: RequestBody) = flow { emit(service.post1(body)) }

fun post12(body: RequestBody, map: Map<String, String>) = flow { emit(service.post12(body, map)) }

fun post1222(id: Long, asr: String) = flow {
val map = mutableMapOf()
map["id"] = id
map["asr"] = asr
val mapHeader = HashMap()
mapHeader["v"] = 1000
mapHeader["device_sn"] = "Avidfasfa1213"
emit(service.post1222(RequestBodyWrapper(Gson().toJson(map)), mapHeader))
}
}

3. viewModel 调用端:

class MainViewModel : BaseViewModel() {

private val repository by lazy { NetRepository.instance }

fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
repository.getHomeList(page).onEach {
android.util.Log.e("MainViewModel", "one 111 ${it.data?.datas!![0].title}")
}
}
}
}

—————————————————我是分割线君—————————————————

  1. 上面花式玩法(一): 此种写法被广泛称作 最优雅的一套网络封装 框架,

    绝大多数中、大厂 基本也就封装到此为止了

    可能还有些人想着:你的 repository 中就返回了 Flow , 里面就全是简单的 emit(xxx) ,我项目里面不是这样的,我的还封装了成功,失败,或者其他的,但总体还是全是模版式的,除了特殊的一些方法,需要在请求前 ,请求后做些处理,有规律有模版的还是占大多数吧,只要大多数都一样的规律模版,都是可以处理的,里面稍微修改下细节,思路都是一样的。

    哪还能有什么玩法?

    可能会有人想到 借助 Hilt ,Dagger2 ,Koin 来创建 Retrofit,和创建 repository,创建 ViewModel 这里不是讨论依赖注入创建对象的事情

    哪还有什么玩法?

    有,必须有的。

三、花式封装(二)

  1. 既然上面是 Repository 类中,所有写法都是固定模版式的代码,那么让其根据 NetApi: 自动生成 Repository 类,我们这里借用注解处理器。
  2. 具体怎么使用介绍,请参考:
    注解处理器在架构,框架中实战应用:MVVM中数据源提供Repository类的自动生成
  3. 本项目中只需要编译 app_wx2 工程
  4. 在下图中找到

img_v3_02f0_d5bd4278-53ac-4008-aac2-abcfdf81668g.jpg 5. viewModel调用端

class MainViewModel : BaseViewModel() {

private val repository by lazy { RNetApiRepository() }

fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
val time = System.currentTimeMillis()
repository.getHomeList(page).onEach {
android.util.Log.e("MainViewModel", "two 222 ${it.data?.datas!![0].title}")
android.util.Log.e("MainViewModel", "耗时:${(System.currentTimeMillis() - time)} ms")
}
}
}
}

6. 如果 Repository 中某个接口方法需要特殊处理怎么办?比如下图,请求前处理一下,从 拿到数据后我需要再次转化处理之后再给到 viewModel 怎么办?

//我这个接口 ,请求前需要 判断处理一下,拿到数据后也需要再处理一下
fun post333(id: Long, asr: String, m: String, n: String, list: List<String>) = flow {
val map = mutableMapOf()
map["id"] = id
map["asr"] = asr
val mapHeader = HashMap()
mapHeader["v"] = 1000
mapHeader["device_sn"] = "Avidfasfa1213"

//接口调用前 根据 需要处理操作
list.forEach {
if (map.containsKey(id.toString())) {
///
}
}

val result = service.post1222(RequestBodyWrapper(Gson().toJson(map)), mapHeader)
// 拿到数据后需要处理操作
val result1 = result
emit(result1)
}.map {
//需要再转化一下
it
}.filter {
//过滤一下
it.length == 3
}

7. 可以在 接口 NetApi 中该方法上配置 @Filter 注解过滤 ,该方法需要自己特殊处理,不自动生成,如下


@Filter
@POST("https://xxxxxxx22222")
suspend fun post333(@Body body: RequestBody, @HeaderMap map: Map): String
  1. 如果想 post请求的 RequestBody 内部参数单独出来进入方法传参,可以加上 在 NetApi 中方法加上 @PostBody:如下:
@PostBody("{"ID":"Long","name":"String"}")
@POST("https://www.wanandroid.com/user/register")
suspend fun testPostBody222(@Body body: RequestBody): String

这样 该方法生成出来的对应方法就是:

public suspend fun testPostBody222(ID: Long, name: java.lang.String): Flow =
kotlinx.coroutines.flow.flow {
val map = mutableMapOf()
map["ID"] = ID
map["name"] = name
val result = service.testPostBody222(com.wx.test.api.retrofit.RequestBodyCreate.toBody(com.google.gson.Gson().toJson(map)))
emit(result)
}

怎么特殊处理,单独手动建一个Repository,针对该方法,单独写,特殊就要特殊手动处理,但是大多数模版式的代码,都可以让其自动生成。

—————————————————我是分割线君—————————————————

到了这里,我们再想, NetApi 是一个接口类,
但是实际上没有写接口实现类啊, 它怎么实现的呢?
我们上面 花式玩法(二) 中虽然是自动生成的,但是还是有方法体,

可不可以再省略点?

可以,必须有!

四、花式玩法(三)

  1. 我们可以根据 NetApi 里面的配置,自动生成 INetApiRepository 接口类, 接口名和参数 都和 NetApi 保持一致,唯一区别就是返回的对象变成了 Flow 了,
    这样在 Repository 中就把数据转变为 flow 流了
  2. 配置让代码自动生成的类:
@AutoCreateRepositoryInterface(interfaceApi = "com.wx.test.api.net.NetApi")
class KaptInterface {
}

生成的接口类 INetApiRepository 代码如下:


public interface INetApiRepository {
public fun getHomeList(): Flow>

public fun getHomeList(page: Int): Flow>

public fun getHomeList(page: Int, f: Float): Flow>

public fun getHomeList(page: Int, a: Int): Flow>

public fun getHomeList2222(page: Int): Flow>

public fun getHomeList3333(page: Int): Flow>

public fun getHomeList5555(
page: Int,
ss: String,
map: Map<String, String>
)
: Flow>

public fun getHomeList6666(
page: Int,
float: Float,
long: Long,
double: Double,
byte: Byte,
short: Short,
char: Char,
boolean: Boolean,
string: String,
body: RequestBodyWrapper
)
: Flow>

public fun getHomeListA(page: Int): Flow>

public fun getHomeListB(page: Int): Flow

public fun post1(body: RequestBody): Flow

public fun post12(body: RequestBody, map: Map<String, String>): Flow

public fun post1222(body: RequestBody, map: Map<String, Any>): Flow

public fun register(
username: String,
password: String,
repassword: String
)
: Flow

public fun testPostBody222(ID: Long, name: java.lang.String): Flow
}
  1. Repository 职责承担的调用端:用动态代理:

class RepositoryPoxy private constructor() : BaseRepositoryProxy() {

val service = NetApi::class.java
val api by lazy { RetrofitUtils.instance.create(service) }


companion object {
val instance by lazy { RepositoryPoxy() }
}

fun callApiMethod(serviceR: Class<R>): R {
return Proxy.newProxyInstance(serviceR.classLoader, arrayOf(serviceR)) { proxy, method, args ->
flow {
val funcds = findSuspendMethod(service, method.name, args)
if (args == null) {
emit(funcds?.callSuspend(api))
} else {
emit(funcds?.callSuspend(api, *args))
}
// emit((service.getMethod(method.name, *parameterTypes)?.invoke(api, *(args ?: emptyArray())) as Call).execute().body())
}.catch {
if (it is InvocationTargetException) {
throw Throwable(it.targetException)
} else {
it.printStackTrace()
throw it
}
}
} as R
}
}
  1. BaseRepositoryProxy 中内容:

open class BaseRepositoryProxy {

private val map by lazy { mutableMapOf?>() }
private val sb by lazy { StringBuffer() }

@OptIn(ExperimentalStdlibApi::class)
fun findSuspendMethod(service: Class<T>, methodName: String, args: Array<out Any>): KFunction<*>? {
sb.delete(0, sb.length)
sb.append(service.name)
.append(methodName)
args.forEach {
sb.append(it.javaClass.typeName)
}
val key = sb.toString()
if (!map.containsKey(key)) {
val function = service.kotlin.memberFunctions.find { f ->
var isRight = 0
if (f.name == methodName && f.isSuspend) {
if (args.size == 0 && f.parameters.size == 1) {
isRight = 2
} else {
f.parameters.forEachIndexed { index, it ->
if (index > 0 && args.size > 0) {
if (args.size == 0) {
isRight = 2
return@forEachIndexed
}
if (it.type.javaType.typeName == javaClassTransform(args[index - 1].javaClass).typeName) {
isRight = 2
} else {
isRight = 1
return@forEachIndexed
}
}
}
}
}
//方法名一直 是挂起函数 方法参数个数一致, 参数类型一致
f.name == methodName && f.isSuspend && f.parameters.size - 1 == args.size && isRight == 2
}
map[key] = function
}
return map[key]
}

private fun javaClassTransform(clazz: Class<Any>) = when (clazz.typeName) {
"java.lang.Integer" -> Int::class.java
"java.lang.String" -> String::class.java
"java.lang.Float" -> Float::class.java
"java.lang.Long" -> Long::class.java
"java.lang.Boolean" -> Boolean::class.java
"java.lang.Double" -> Double::class.java
"java.lang.Byte" -> Byte::class.java
"java.lang.Short" -> Short::class.java
"java.lang.Character" -> Char::class.java
"SingletonMap" -> Map::class.java
"LinkedHashMap" -> MutableMap::class.java
"HashMap" -> HashMap::class.java
"Part" -> MultipartBody.Part::class.java
"RequestBody" -> RequestBody::class.java
else -> {
if ("RequestBody" == clazz.superclass.simpleName) {
RequestBody::class.java
} else {
Any::class.java
}
}
}
}
  1. ViewModel中调用端:
class MainViewModel : BaseViewModel() {

private val repository by lazy { RepositoryPoxy.instance }

fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
val time = System.currentTimeMillis()
repository.callApiMethod(INetApiRepository::class.java).getHomeList(page).onEach {
android.util.Log.e("MainViewModel", "three 333 ${it.data?.datas!![0].title}")
android.util.Log.e("MainViewModel", "耗时:${(System.currentTimeMillis() - time)} ms")
}
}
}
}

—————————————————我是分割线君—————————————————

  1. 上面生成的接口类 INetApiRepository 其实方法和 NetApi 拥有相似的模版,唯一区别就是返回类型,一个是对象,一个是Flow 流的对象

    还能省略吗?

    有,必须有

五、花式玩法(四)

  1. 直接修改 RepositoryPoxy ,作为Reposttory的职责 ,连上面的 INetApiRepository 的接口类全部省略了, 如下:
class RepositoryPoxy private constructor() : BaseRepositoryProxy() {

val service = NetApi::class.java
val api by lazy { RetrofitUtils.instance.create(service) }


companion object {
val instance by lazy { RepositoryPoxy() }
}

fun callApiMethod(clazzR: Class<R>, methodName: String, vararg args: Any): Flow {
return flow {
val clssss = mutableListOfout Any>>()
args?.forEach {
clssss.add(javaClassTransform(it.javaClass))
}
val parameterTypes = clssss.toTypedArray()
val call = (service.getMethod(methodName, *parameterTypes)?.invoke(api, *(args ?: emptyArray())) as Call)
call?.execute()?.body()?.let {
emit(it as R)
}
}
}

@OptIn(ExperimentalStdlibApi::class)
fun callApiSuspendMethod(clazzR: Class<R>, methodName: String, vararg args: Any): Flow {
return flow {
val funcds = findSuspendMethod(service, methodName, args)
if (args == null) {
emit(funcds?.callSuspend(api) as R)
} else {
emit(funcds?.callSuspend(api, *args) as R)
}
}
}
}

2. ViewModel中调用入下:

class MainViewModel : BaseViewModel() {

private val repository by lazy { RepositoryPoxy.instance }

fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
val time = System.currentTimeMillis()
repository.callApiSuspendMethod(HomeData::class.java, "getHomeListB", page).onEach {
android.util.Log.e("MainViewModel", "four 444 ${it.data?.datas!![0].title}")
android.util.Log.e("MainViewModel", "耗时:${(System.currentTimeMillis() - time)} ms")
}
}
}
}

六、总结

通过上面4中花式玩法:

  1. 花式玩法1: 我们知道了最常见最优雅的写法,但是模版式 repository 代码太多,而且需要手动写
  2. 花式玩法2: 把花式玩法1中的模版式 repository ,让其自动生成,对于特殊的方法,单独手动再写个 repository ,这样让大多数模版式代码全自动生成
  3. 花式玩法3: NetApi,可以根据配置,动态代理生成网络请求行为,该行为统一为动态代理实现,无需对接口类 NetApi 单独实现,那么我们的 repository 也可以 生成一个接口类 INetApiRepository ,然后动态代理实现其内部 方法体逻辑
  4. 花式玩法4:我连花式玩法3中的接口类 INetApiRepository 都不需要了,直接反射搞定所有。
  5. 同时可以学习到,注解、反射、泛型、注解处理器、动态代理

项目地址

项目地址:
github地址
gitee地址

感谢阅读:

欢迎 点赞、收藏、关注


作者:Wgllss
来源:juejin.cn/post/7417847546323042345
收起阅读 »

Flutter 用什么架构方式才合理?

前言 刚入门 Flutter 编程时,差点被 Flutter 的嵌套地狱吓走,不过当我看到 Flutter 支持 Windows 稳定后,于是下定决心尝试接受 Flutter,因为 Flutter 真的给的太多了:跨平台、静态编译、热加载界面。 Flutter...
继续阅读 »

前言


刚入门 Flutter 编程时,差点被 Flutter 的嵌套地狱吓走,不过当我看到 Flutter 支持 Windows 稳定后,于是下定决心尝试接受 Flutter,因为 Flutter 真的给的太多了:跨平台、静态编译、热加载界面。


Flutter 代码是写到文件夹中的,通过文件夹来管理代码,像是 c++ 语言那样,一个文件,即可以写类,也可以直接写方法😠。


不像 java 那样,全部都是类,整齐划一,通过包名来管理,但也支持类似的“导包”😆。


那么怎样才能像 Java 那样,有个框架优化代码,让项目看起来更整洁好维护呢?


我目前的答案是 MVC 🐷,合适自己的架构才是最好的架构,用这个架构,我感觉找到了家,大家先看看我的代码,然后再做评价。


使用部分


结合GetX, 使用方式如下:


import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:wenznote/commons/mvc/controller.dart';
import 'package:wenznote/commons/mvc/view.dart';

class CustomController extends MvcController {
var count = 0.obs;

void addCount() {
count.value++;
}
}

class CustomView extends MvcView<CustomController> {
const CustomView({super.key, required super.controller});

@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: [
Obx(() => Text("点击次数:${controller.count.value}")),
TextButton(
onPressed: () {
controller.addCount();
},
child: Text("点我"),
),
],
),
);
}
}

简单粗暴,直接在 CustomView 中设计 UI, 在 CustomController 中编写业务逻辑代码,比如登录注册之类的操作。


至于 MVC 中的 Model 去哪里了?你猜猜😘。


代码封装部分


代码封装也很简洁,封装的 controller 代码如下


import 'package:flutter/material.dart';

class MvcController with ChangeNotifier {
late BuildContext context;

@mustCallSuper
void onInitState(BuildContext context) {
this.context = context;
}

@mustCallSuper
void onDidUpdateWidget(BuildContext context, MvcController oldController) {
this.context = context;
}

void onDispose() {}
}

封装的 view 代码如下


import 'package:flutter/material.dart';
import 'controller.dart';

typedef MvcBuilder<T> = Widget Function(T controller);

class MvcView<T extends MvcController> extends StatefulWidget {
final T controller;
final MvcBuilder<T>? builder;

const MvcView({
super.key,
required this.controller,
this.builder,
});

Widget build(BuildContext context) {
return builder?.call(controller) ?? Container();
}

@override
State<MvcView> createState() => _MvcViewState();
}

class _MvcViewState extends State<MvcView> with AutomaticKeepAliveClientMixin{
@override
bool get wantKeepAlive => true;

@override
void initState() {
super.initState();
widget.controller.onInitState(context);
widget.controller.addListener(onChanged);
}

void onChanged() {
if (context.mounted) {
setState(() {});
}
}

@override
Widget build(BuildContext context) {
super.build(context);
widget.controller.context = context;
return widget.build(context);
}

@override
void didUpdateWidget(covariant MvcView<MvcController> oldWidget) {
super.didUpdateWidget(oldWidget);
widget.controller.onDidUpdateWidget(context, oldWidget.controller);
}


@override
void dispose() {
widget.controller.removeListener(onChanged);
widget.controller.onDispose();
super.dispose();
}
}

结语


MVC 可以很简单快速的将业务代码和 UI 代码隔离开,改逻辑的时候就去找 Controller 就行,改 UI 的话就去找 View 就行,和后端开发一样的思路,完成作品就行。


附上的作品文件结构截图,亲喷哈~


04ab4670-d62d-11ee-b1e9-af9546993c52.png


感谢大家的关注与支持,后续继续更新更多 flutter 跨平台开发知识,例如:MVC 架构中的 Controller 应该在哪里创建?Controller 中的 Service 应该在哪里创建?


作品地址:github.com/lyming99/we…


作者:果冻橙橙君
来源:juejin.cn/post/7340472228927914024
收起阅读 »

转转的Flutter实践之路

前言 跨端技术一直是移动端开发领域的热门话题,Flutter 作为一种领先的移动跨端技术之一,凭借其快速的渲染引擎、丰富的UI组件库和强大的开发工具,成为了开发人员的首选之一。 从 Flutter 诞生之初,我们就一直关注着它的发展,Flutter 早期版本变...
继续阅读 »

前言


跨端技术一直是移动端开发领域的热门话题,Flutter 作为一种领先的移动跨端技术之一,凭借其快速的渲染引擎、丰富的UI组件库和强大的开发工具,成为了开发人员的首选之一。


从 Flutter 诞生之初,我们就一直关注着它的发展,Flutter 早期版本变更较为频繁,并且经常伴随着 Breaking Change,另外可用的三方插件较少且不稳定。直到2019年,Flutter 的热度暴涨,国内不少团队陆续把 Flutter 引入到了生产环境使用,社区也涌现出不少优秀的开源项目,我们也决定在这个时候做一些技术上的尝试。


经过这几年在 Flutter 技术上的不断学习、探索和积累,Flutter 已经成为了客户端技术体系中的重要组成部分。


回顾整个过程,我们大致经历了这么几个阶段:可行性验证、基建一期建设、小范围试验、基建二期建设、大范围推广、前端生态的探索,下文将分别对每个阶段展开进行介绍。


可行性验证


其实在这之前我们已经做过了一些调研,但许多结论都是来源于网上的一些文章或者其它团队的实践,这些结论是否靠谱是否真实还有待商榷,另外,网上的文章大都千篇一律,要么使劲吹捧,要么使劲贬低,要得出相对客观的结论还是得需要我们自己通过实践才能得出。


目标


我们确定了以下几个维度,用来评估 Flutter 是否值得我们进一步投入:



  • 开发效率

  • UI一致性

  • 性能体验

  • 学习成本

  • 发展趋势


由于前期对 Flutter 的熟练度不高,基础设施也还没有搭建起来,所以在开发效率上,我们期望的 Flutter 的开发耗时能保持在原生开发耗时的 1.5 倍以内,不然虽然实现了跨端,但是需求的开发周期反而被拉长了,这样得不偿失。在UI一致性上,我们期望同一份代码在两端的表现要基本达到一致,不需要额外的适配成本。在性能方面,尽量保证崩溃、卡顿、内存、帧率这些指标在可控范围内。


方案


我们希望用较小的代价完成上述维度的评估,所以在试验期间的架构及基础设施方面我们做的比较简单。


测试目标


当时我们正在做一个叫切克的 App,用户量级比较小,工程架构也相对简单一些,正好可以用来做一些技术方面的探索和验证。


我们选择的是切克的商品详情页,用 Flutter 技术实现了一个一模一样的商详,按1:1的流量分配给 Native 和 Flutter。


项目架构


由于我们的工程不是一个全新的项目,所以采用的是 Native 与 Flutter 混合开发的方式,Native 主工程只依赖 Flutter 产物即可,同时也尽量避免对原有工程的影响。


关于混合页面栈的问题,我们没有额外处理,因为暂时只测试一个页面,不会涉及到多页面混合栈的问题,所以暂时先忽略。


构建流程


为了降低验证成本,我们没有对接现有的 Native 的持续集成流程,而是直接在本地构建 Flutter 产物,然后上传到远程仓库。


结论


经过一段时间的线上验证,我对 Flutter 技术基本有了一个比较全面的了解:


在开发效率上由于基础库和基建的缺失,在处理 Flutter 业务跟 Native 业务的交互时需要更多的适配成本,包括像页面跳转、埋点上报、接口请求、图片加载等也需要额外的处理,但我们评估随着后续基建的不断完善,这部分的效率是可以逐步得到改善的;而在涉及UI开发方面,得益于热重载等技术,Flutter 的开发效率是要优于原生开发的。整体评估下来,在开发效率方面 Flutter 是符合我们的预期的。


在UI一致性上,除了在状态栏控制和文本在某些情况下需要特殊适配下外,其它控件在两端的表现基本一致。


在性能表现上,Flutter 会额外引入一些崩溃,内存占用也有所上涨,但还在可接受范围内。


Flutter 的学习成本相对还是比较高,毕竟需要单独学习一门语言,另外 Flutter 的渲染原理也跟原生有很多差异,需要转变思维才能更快的适应,此外 Flutter 还提供了众多的 Widget 组件,也需要较长时间学习。


在发展趋势上,Flutter 无疑是当时增长最快的跨端技术之一,社区的活跃程度以及官方的投入都非常高,国内不少团队也都在积极推进 Flutter 技术的发展,Flutter 正处在一个快速的上升期。


整体来说,Flutter 是满足我们团队对跨平台技术的需求的,我们计划在接下来的一段时间投入更多资源,把 Flutter 的基础设施逐渐建立起来。


基建一期建设


基建一期内容主要包括以下几个方面:



  • 工程架构

  • 开发框架

  • 脚本工具

  • 自动化构建


在基建一期完成后,我们的目标是要达到:



  • 基础能力足够支撑普通业务开发

  • 开发效率接近原生开发

  • 开发过程要基本顺畅


工程架构


工程架构指的是原生工程与 Flutter 工程之间的关系,以及 Flutter 工程与 Flutter 工程之间的关系。


原生工程与Flutter工程的关系


我们知道,使用 Flutter 开发通常有两种情况,一种是直接使用 Flutter 开发一个新的App,属于纯 Flutter 开发;一种是在已有的 Native 工程中引入,属于混合开发。我们当然属于后者。


而混合开发又可分为两种:源码集成和产物集成。源码集成需要改变原工程的项目结构,并且需要 Flutter 开发环境才能编译,而产物集成则不需要改动原工程的项目结构,只需把 Flutter 的构建产物当作普通的依赖库引入即可,原有 Native 工程和 Flutter 工程从物理上完全独立。显而易见的我们选择产物集成的方式,引入 Flutter对于原工程以及非 Flutter 开发人员来说,基本上是毫无感知的。


所以原生工程与 Flutter 工程之间的关系如下图所示:


原生工程与Flutter工程之间的关系


Flutter工程之间的关系


根据已有的客户端基建的开发经验,我们将所有 Flutter 工程分为了四层:



  • 壳工程

  • 业务层

  • 公共层

  • 容器层


容器层负责提供 Flutter 的基础运行环境,包括 Flutter 引擎管理、页面栈管理、网络框架、KV存储、数据库访问、埋点框架、Native 与 Flutter 通信通道和其它基础功能。


公共层包含一些通用的开源库、自定义UI组件、部分通用业务等。


业务层包含用户信息、商品、发布等业务组件。


壳工程负责集成各业务组件,最终构建出产物集成到 Native 主工程。


其中业务层、公共层、容器层都是由若干个独立的工程所组成,整体结构如下:


Flutter分层架构


开发框架


开发框架是为了提高开发效率、规范代码结构、减少维护成本等考虑而设计的一套软件框架,包括:基础能力、状态管理、页面栈管理等。


基础能力


开发框架需要提供各种必要的能力,比如:页面跳转、埋点、网络请求、图片加载、数据存储等,为了最大化减少研发成本,我们在底层定义了一套通用的数据交互协议,直接复用了现有的 Native 的各项能力,也使得 Native 的各种状态与 Flutter 侧能够保持统一。


状态管理


相信了解 Flutter 的同学一定知道状态管理,这也是跟 Native 开发区别较大的地方。在开发较为复杂的页面时,状态维护是非常繁琐的,在不引入状态管理框架的情况下,开发效率会受很大影响,后期的维护成本以及业务交接都是很大的问题。


另外,在开发框架设计之初,我们就期望从框架上能够在一定程度上限定代码结构、模块之间的交互方式、状态更新方式等,我们期望的是不同的人写出来的代码在逻辑、结构和风格上都能保持比较统一,即在提高开发效率的同时,也能保证项目后续的可维护性和扩展性,减少不同业务间的交接成本。


基于上述这些需求,在我们对比了多个开源项目后,FishRedux 的整体使用感受正好符合我们的要求。


如下图,两个页面的代码结构基本一致:


收藏详情和个人主页


页面栈管理


在早期版本,Flutter 引擎的实例占用内存较高,为了减少内存消耗,大家普遍采用单实例的模式,而在 Native 和 Flutter 混合开发的场景下就会存在一个问题,就是 Native 有自己的页面栈,而 Flutter 也维护着一套自己的页面栈,如果 Native 页面与 Flutter 页面穿插着打开,在没有特殊处理的情况下,页面栈会发生错乱。在调研了业内的各种开源方案后,我们选择引入 FlutterBoost 用来管理页面混合栈。


脚本工具


为了方便开发同学搭建 Flutter 的开发环境,同时能够管理使用的 Flutter 版本,我们开发了 zflutter 命令行工具,包含以下主要功能:



  • Flutter开发环境安装

  • Flutter版本管理

  • 创建模版工程(主工程、组件工程)

  • 创建模版页面(常规页面、列表页、瀑布流页面)

  • 创建页面模块

  • 组件工程发布

  • 构建Flutter产物

  • 脚本自更新


如图:


zflutter


自动化构建


客户端使用的是自研的 Beetle 平台(集工程管理、分支管理、编译、发布于一体),短时间内要支持上 Flutter 不太现实,基于此,我们先临时自己搭台服务器,通过 gitlab 的 webhook 功能结合 zflutter 工具简单实现了一套自动化构建的服务,待 Beetle 支持 Flutter 组件化开发功能后,再将工作流切回到 Beetle 平台。


小范围试验


在完成基建一期的开发工作后,我们决定通过开发几个实际业务来试验目前的基础设施是否达到既定目标。


我们以不影响主流程、能覆盖常见UI功能、并且能跟 Native 页面做AB测试(主要是方便在出问题时能够切换到 Native 版本)为条件挑选了个人资料页和留言列表页进行了 Flutter 化改造,如下图所示:


个人资料页/留言列表页


这两个页面涵盖了网络请求、图片加载、弹窗、列表、下拉刷新、上拉加载更多、左滑删除、埋点上报、页面跳转等常见功能,足以覆盖日常开发所需的基础能力。


经过完整的开发流程以及一段时间的线上观察,我们得出如下结论:


基础能力


目前已具备的基础能力已经足够支撑普通业务开发(开发过程中补足了一些缺失的能力)。


工作流


整个开发过程在工程依赖管理和分支管理方面的支持还比较缺失,比较依赖人工处理。


开发效率


我们在开发前根据页面功能同时做了纯 Native 开发排期和 Flutter 开发排期,按单人日的成本来对比的话,Flutter 实际开发耗时跟 Native 排期耗时比为 1.25:2,Native 是按照 Android+iOS 两端各一人算的,也就是1.25人/日比2人/日,如果后续对 Flutter 技术熟悉度提升后相信效率还可以进一步提升。


性能体验


线上两个 Flutter 页面的体验效果跟 Native 对比基本感觉不到差别,但是首次进入 Flutter 页面时会有短暂的白屏等待时间,这个是由于 Flutter 环境初始化导致的延迟,后续可以想办法优化。


包体积


在引入 Flutter 之后,转转的安装包体积在两端都分别有所增加:



  • Android增加6.1M

  • iOS增加14M


试验结果基本符合预期,包体积的增量也在我们的可接受范围内,接下来将进行基建二期的建设,补足目前缺失的能力。


基建二期建设


基建二期的内容主要包含以下工作:



  • 配合工程效率组完成 Beetle 对 Flutter 项目的支持

  • 组织客户端内部进行 Flutter 技术培训


Beetle支持Flutter


为了能让大家更清晰的了解 Beetle 的工程管理机制,这里先简单介绍下客户端的工程类型:



  • Native主工程(又分为 Android 和 iOS)

  • Native组件工程(又分为 Android 和 iOS)

  • Flutter主工程

  • Flutter组件工程(即 Flutter 插件工程)


举个例子,当有一个新版本需要开发时,先从 Native 主工程创建一个版本同时创建一个 Release 分支,即版本分支,然后从版本分支根据具体需求创建对应 Native 组件的版本分支,Flutter 主工程此时可看作是一个 Native 组件,比如此时创建了一个 Flutter 主工程的版本分支后,可以进入 Flutter 主工程再根据需要创建对应的 Flutter 组件工程的版本分支。


Beetle 目前已支持 Flutter 工程管理、分支管理、组件依赖管理以及组件的发布、Flutter 产物的构建等,Beetle 的作用贯穿从开发到上线的整个工作流。


Flutter技术培训


为了让大家更快的熟悉 Flutter 开发,我们在客户端内部组织了5次 Flutter 快速入门的系列分享:


Flutter快速入门系列


同时也逐步完善内部文档的建设,包括:FlutterSdk 源码维护策略、Flutter 入门指南、Flutter 混合开发方案、Flutter 与 Native 通信方案、Flutter 开发环境配置、Flutter 组件化工程结构、Flutter 开发与调试、Flutter 开发工作流、ZFlutter 工具使用介绍、Flutter 开发之 Beetle 使用指南等,涵盖了从环境搭建、开发调试到构建发布的整个过程。


大范围推广


在完成基建二期的建设后,整体基础设施已经能够支撑我们常见的业务,开发工作流也基本顺畅,于是我们开始了在内部大范围推广计划。


我们先后改造和新开发了个人主页、我发布的页面、微商详、奇趣数码页等业务,基本涵盖了常见的各种类型的页面和功能,整体开发效率与原生单端开发效率持平,但是在特别复杂的页面的性能表现上,Flutter 的表现相对要差一些。


部分页面如下图所示:


个人主页


微详情页/我发布的/奇趣数码


探索前端生态


在跨端技术领域我们知道 Web 技术是天然支持的,如果能把前端生态引入到 Flutter 中,那么对客户端来说,在业务的支持度上会更上一个台阶,Web 的体验得到提升的同时客户端也具备了动态化,基于此背景我们开始探索 Flutter 在 Web 上的可能性。


技术调研


当时可选的开源方案有:Kraken、MXFlutter、Flutter For Web。


Kraken


Kraken 是一款基于 W3C 标准的高性能渲染引擎。Kraken 底层基于 Flutter 进行渲染,通过其自绘渲染的特性,保证多端一致性。上层基于 W3C 标准实现,拥有非常庞大的前端开发者生态。


Kraken 的最上层是一个基于 W3C 标准而构建的 DOM API,在下层是所依赖的 JS 引擎,通过 C++ 构建一个 Bridge 与 Dart 通信。然后这个 C++ Bridge 把 JS 所调用的一些信息,转发到 Dart 层。Dart 层通过接收这些信息,会去调用 Flutter 所提供的一些渲染能力来进行渲染。


Kraken 是不依赖 Flutter Widget,而是依赖 Flutter Widget 的底层渲染数据结构 —— RenderObject。Kraken 实现了很多 CSS 相关的能力和一些自定义的 RenderObject,直接将生成的 RenderObject 挂载在 Flutter RenderView 上来进行渲染,通过这样的方式能够做到非常高效的渲染性能。


MXFlutter


MXFlutter 是一套使用 TypeScript/JavaScript 来开发 Flutter 应用的框架。


MXFlutter 把 Flutter 的渲染逻辑中的三棵树(即:WidgetTree、Element、RenderObject )中的第一棵(即:WidgetTree),放到 JavaScript 中生成。用 JavaScript 完整实现了 Flutter 控件层封装,实现了轻量的响应式 UI 框架,支撑JS WidgetTree 的 build逻辑,build 过程生成的UI描述, 通过Flutter 层的 UI 引擎转换成真正的 Flutter 控件显示出来。


Flutter For Web


Flutter 在 Web 平台上以浏览器的标准 API 重新实现了引擎。目前有两种在 Web 上呈现内容的选项:HTML 和 WebGL。



  • 在 HTML 模式下,Flutter 使用 HTML、CSS、Canvas 和 SVG 进行渲染。

  • 在 WebGL 模式下,Flutter 使用了一个编译为 WebAssembly 的 Skia 版本,名为 CanvasKit。


HTML 模式提供了最佳的代码大小,CanvasKit 则提供了浏览器图形堆栈渲染的最快途径,并为原生平台的内容提供了更高的图形保真度。


结论


我们对以上方案从接入成本、渲染性能、包体积、开发生态、学习成本等多维度进行了对比:



  • 接入成本:Kraken ≈ MXFlutter ≈ Flutter For Web

  • 渲染性能:Kraken > MXFlutter > Flutter For Web

  • 包体积增量:Flutter For Web < Kraken < MXFlutter

  • 开发生态:Kraken ≈ MXFlutter > Flutter For Web

  • 学习成本:Flutter For Web < Kraken ≈ MXFlutter


最终选择了 Kraken 作为我们的首选方案。


上线验证


为了使 Kraken 顺利接入转转App,我们做了以下几个方面的工作:



  • 升级 FlutterSdk 到最新版,满足接入 Kraken 的基础条件

  • 统一客户端容器接口,使得 Kraken 容器能够完美继承 Web 容器的能力

  • 自己维护 Kraken 源码,及时修复官方来不及修复的问题,方便增加转转特有的扩展能力

  • 制定 Kraken 容器与 Web 容器的降级机制

  • 兼容 HTML 加载,保持跟 Web 容器一致的加载方式

  • 添加监控埋点,量化指标,指导后续优化方向

  • 选择一个简单 Web 页并协助前端同学适配


上线后,我们对页面的各项指标进行了对比,使用 Kraken 容器加载比使用 WebView 加载,在首屏加载耗时的指标上平均增加了281毫秒,原因为:当前版本的 Kraken 容器不支持直接加载 HTML,且只能加载单个 JsBundle,导致加载效率比 WebView 差。


通过跟前端同学沟通,从开发效率上来看,Kraken 工程的开发周期会比实现同样需求的普通 Web 工程增加1.5到2倍的时间,主要原因是受到 CSS 样式、Api 差异,无法使用现有UI组件,另外 Kraken 的调试工具目前还不够完善,使用浏览器调试后还须在客户端容器中调试,整体下来导致开发 Kraken 工程会比开发普通Web工程耗费更多时间。


再次验证


由于之前选择的 Web 页面太过简单,不具备代表性,所以我们重新选定了“附近的人”页面做为改造目标,再次验证 Kraken 在实际开发过程中的效率及性能体验。页面如图所示:


附近的人


最终因为部分问题得不到解决,并且整体性能较差,导致页面没能成功上线。


存在的问题包括但不限于下面列举的一些:



  • 表现不一致问题

    1. CSS 定位、布局表现与浏览器表现不一致

    2. 部分 API 表现与浏览器不一致(getBoundingClientRect等)

    3. iOS,Android系统表现不一致



  • 重大 Bug

    1. 页面初始化渲染完成,动态修改元素样式,DOM不重新渲染

    2. 滑动监听计算导致 APP 崩溃



  • 调试成本高

    1. 不支持 vue-router,单项目单路由

    2. 不支持热更新,npm run build 预览

    3. 不支持 sourceMap,无法定位源代码

    4. 真机调试只支持 element 和 network;dom 和 element 无法互相选中;无法动态修改 dom 结构,无法直接修改样式.......

    5. 页面白屏,假死



  • 安全性问题

    1. 无浏览器中的“同源策略”限制



  • 兼容性

    1. npm 包不兼容等




通过这一系列的探索和尝试,我们了解到了 Kraken 目前还存在许多不足,如果继续应用会带来高额的开发调试以及维护成本,所以暂时停止了在 Kraken 方向上的投入,但我们仍然在这个方向上保持着关注。


结尾


目前转转在Flutter方向上的实践和探索只是一个起点,我们意识到仍然有很多工作需要去做。我们坚信Flutter作为一项领先的跨端技术,将为转转业务的发展带来巨大的潜力和机会。我们将持续努力,加强技术建设,不断完善实践经验,推动Flutter在转转的应用和发展,为用户提供更好的产品和体验。



转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。




关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~



作者:转转技术团队
来源:juejin.cn/post/7304831120709697588
收起阅读 »

Flutter 中在单个屏幕上实现多个列表

今天,我将提供一个实际的示例,演示如何在单个页面上实现多个列表,这些列表可以水平排列、网格格式、垂直排列,甚至是这些常用布局的组合。 下面是要做的: 实现 让我们从创建一个包含产品所有属性的产品模型开始。 class Product { final St...
继续阅读 »


今天,我将提供一个实际的示例,演示如何在单个页面上实现多个列表,这些列表可以水平排列、网格格式、垂直排列,甚至是这些常用布局的组合。


下面是要做的:
转存失败,建议直接上传图片文件


实现


让我们从创建一个包含产品所有属性的产品模型开始。


class Product {
final String id;
final String name;
final double price;
final String image;

const Product({
required this.id,
required this.name,
required this.price,
required this.image,
});

factory Product.fromJson(Map json) {
return Product(
id: json['id'],
name: json['name'],
price: json['price'],
image: json['image'],
);
}
}

现在,我们将设计我们的小部件以支持水平、垂直和网格视图。


创建一个名为 HorizontalRawWidget 的新窗口小部件类,定义水平列表的用户界面。


import 'package:flutter/material.dart';
import 'package:multiple_listview_example/models/product.dart';

class HorizontalRawWidget extends StatelessWidget {
final Product product;

const HorizontalRawWidget({Key? key, required this.product})
: super(key: key);

@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(
left: 15,
),
child: Container(
width: 125,
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(12)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(5, 5, 5, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
product.image,
height: 130,
fit: BoxFit.contain,
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(product.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.black,
fontSize: 12,
fontWeight: FontWeight.bold)),
),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text("\$${product.price}",
style: const TextStyle(
color: Colors.black, fontSize: 12)),
],
),
],
),
),
)
],
),
),
);
}
}

设计一个名为 GridViewRawWidget 的小部件类,定义单个网格视图的用户界面。


import 'package:flutter/material.dart';
import 'package:multiple_listview_example/models/product.dart';

class GridViewRawWidget extends StatelessWidget {
final Product product;

const GridViewRawWidget({Key? key, required this.product}) : super(key: key);

@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(10)),
child: Column(
children: [
AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.network(
product.image,
fit: BoxFit.fill,
),
),
)
],
),
);
}
}

最后,让我们为垂直视图创建一个小部件类。


import 'package:flutter/material.dart';
import 'package:multiple_listview_example/models/product.dart';

class VerticalRawWidget extends StatelessWidget {
final Product product;

const VerticalRawWidget({Key? key, required this.product}) : super(key: key);

@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 15, vertical: 5),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
color: Colors.white,
child: Row(
children: [
Image.network(
product.image,
width: 78,
height: 88,
),
const SizedBox(
width: 15,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: const TextStyle(fontSize: 12, color: Colors.black, fontWeight: FontWeight.bold),
),
SizedBox(
height: 5,
),
Text("\$${product.price}",
style: const TextStyle(color: Colors.black, fontSize: 12)),
],
),
)
],
),
);
}
}

现在是时候把所有的小部件合并到一个屏幕中了,我们先创建一个名为“home_page.dart”的页面,在这个页面中,我们将使用一个横向的 ListView、纵向的 ListView 和 GridView。


import 'package:flutter/material.dart';
import 'package:multiple_listview_example/models/product.dart';
import 'package:multiple_listview_example/utils/product_helper.dart';
import 'package:multiple_listview_example/views/widgets/gridview_raw_widget.dart';
import 'package:multiple_listview_example/views/widgets/horizontal_raw_widget.dart';
import 'package:multiple_listview_example/views/widgets/title_widget.dart';
import 'package:multiple_listview_example/views/widgets/vertical_raw_widget.dart';

class HomePage extends StatelessWidget {
const HomePage({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
List products = ProductHelper.getProductList();
return Scaffold(
backgroundColor: const Color(0xFFF6F5FA),
appBar: AppBar(
centerTitle: true,
title: const Text("Home"),
),
body: SingleChildScrollView(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const TitleWidget(title: "Horizontal List"),
const SizedBox(
height: 10,
),
SizedBox(
height: 200,
child: ListView.builder(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
itemCount: products.length,
itemBuilder: (BuildContext context, int index) {
return HorizontalRawWidget(
product: products[index],
);
}),
),
const SizedBox(
height: 10,
),
const TitleWidget(title: "Grid View"),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 15, vertical: 10),
child: GridView.builder(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 13,
mainAxisSpacing: 13,
childAspectRatio: 1),
itemCount: products.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (BuildContext context, int index) {
return GridViewRawWidget(
product: products[index],
);
}),
),
const TitleWidget(title: "Vertical List"),
ListView.builder(
itemCount: products.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (BuildContext context, int index) {
return VerticalRawWidget(
product: products[index],
);
}),
],
),
),
),
);
}
}

我使用了一个 SingleChildScrollView widget 作为代码中的顶部根 widget,考虑到我整合了多个布局,如水平列表、网格视图和垂直列表,我将所有这些 widget 包装在一个 Column widget 中。


挑战在于如何处理多个滚动部件,因为在上述示例中有两个垂直滚动部件:一个网格视图和一个垂直列表视图。为了禁用单个部件的滚动行为, physics 属性被设置为 const NeverScrollableScrollPhysics()。取而代之的是,使用顶层根 SingleChildScrollView`` 来启用整个内容的滚动。此外,SingleChildScrollView上的shrinkWrap属性被设置为true`,以确保它能紧紧包裹其内容,只占用其子控件所需的空间。


Github 链接github.com/tarunaronno…


作者:独立开发者张张
来源:juejin.cn/post/7302070112638468147
收起阅读 »

flutter3-douyin:基于flutter3.x+getx+mediaKit短视频直播App应用

经过大半个月的爆肝式开发输出,又一个跨端新项目Flutter-Douyin短视频正式完结了。 flutter3_douyin基于最新跨平台技术flutter3.19.2开发手机端仿抖音app实战项目。 实现了类似抖音全屏沉浸式上下滑动视频、左右滑动切换页面...
继续阅读 »

经过大半个月的爆肝式开发输出,又一个跨端新项目Flutter-Douyin短视频正式完结了。


未标题-2.png


flutter3_douyin基于最新跨平台技术flutter3.19.2开发手机端仿抖音app实战项目。


未标题-1.png


实现了类似抖音全屏沉浸式上下滑动视频、左右滑动切换页面模块,直播间进场/礼物动画,聊天等模块功能。


p2.gif


使用技术



  • 编辑器:vscode

  • 技术框架:flutter3.19.2+dart3.3.0

  • 路由/状态插件:get: ^4.6.6

  • 本地缓存服务:get_storage: ^2.1.1

  • 图片预览插件:photo_view: ^0.14.0

  • 刷新加载:easy_refresh^3.3.4

  • toast轻提示:toast^0.3.0

  • 视频套件:media_kit: ^1.1.10+1


p4.gif


p6.gif


项目结构


360截图20240324084015379.png


前期需要配置好flutter和dart sdk环境。如果使用vscode编辑器,可以安装一些flutter语法插件。


p5.gif


更多的开发api资料,大家可以去官网查阅就行。


flutter.dev/

flutter.cn/

pub.flutter-io.cn/

http://www.dartcn.com/


001360截图20240323222155689.png


002360截图20240323231117743.png


003360截图20240323231319575.png


003360截图20240323231521845.png


003360截图20240323231930830.png


该项目涉及到的技术知识还是蛮多的。下面主要介绍一些短视频及直播知识,至于其它知识点,大家可以去看看之前分享的flutter3聊天项目文章。


http://www.cnblogs.com/xiaoyan2017…


http://www.cnblogs.com/xiaoyan2017…


flutter主入口lib/main.dart


import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:media_kit/media_kit.dart';

import 'utils/index.dart';

// 引入布局模板
import 'layouts/index.dart';

import 'binding/binding.dart';

// 引入路由管理
import 'router/index.dart';

void main() async {
// 初始化get_storage
await GetStorage.init();

// 初始化media_kit
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();

runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: 'FLUTTER3 DYLIVE',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFFE2C55)),
useMaterial3: true,
// 修正windows端字体粗细不一致
fontFamily: Platform.isWindows ? 'Microsoft YaHei' : null,
),
home: const Layout(),
// 全局绑定GetXController
initialBinding: GlobalBindingController(),
// 初始路由
initialRoute: Utils.isLogin() ? '/' : '/login',
// 路由页面
getPages: routePages,
// 错误路由
// unknownRoute: GetPage(name: '/404', page: Error),
);
}
}

flutter3自定义底部凸起导航


image.png


采用 bottomNavigationBar 组件实现页面模块切换。通过getx状态管理联动控制底部导航栏背景颜色。导航栏中间图标/图片按钮,使用了 Positioned 组件实现功能。


return Scaffold(
backgroundColor: Colors.grey[50],
body: pageList[pageCurrent],
// 底部导航栏
bottomNavigationBar: Theme(
// Flutter去掉BottomNavigationBar底部导航栏的水波纹
data: ThemeData(
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
hoverColor: Colors.transparent,
),
child: Obx(() {
return Stack(
children: [
Container(
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: Colors.black54, width: .1)),
),
child: BottomNavigationBar(
backgroundColor: bottomNavigationBgcolor(),
fixedColor: FStyle.primaryColor,
unselectedItemColor: bottomNavigationItemcolor(),
type: BottomNavigationBarType.fixed,
elevation: 1.0,
unselectedFontSize: 12.0,
selectedFontSize: 12.0,
currentIndex: pageCurrent,
items: [
...pageItems
],
onTap: (index) {
setState(() {
pageCurrent = index;
});
},
),
),
// 自定义底部导航栏中间按钮
Positioned(
left: MediaQuery.of(context).size.width / 2 - 15,
top: 0,
bottom: 0,
child: InkWell(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icon(Icons.tiktok, color: bottomNavigationItemcolor(centerDocked: true), size: 32.0,),
Image.asset('assets/images/applogo.png', width: 32.0, fit: BoxFit.contain,)
// Text('直播', style: TextStyle(color: bottomNavigationItemcolor(centerDocked: true), fontSize: 12.0),)
],
),
onTap: () {
setState(() {
pageCurrent = 2;
});
},
),
),
],
);
}),
),
);

flutter3实现抖音滑动效果


8f4719d0fcb39785377fb25f00c70663_1289798-20240324105714095-552535108.png


003360截图20240323231731725.png


return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
forceMaterialTransparency: true,
backgroundColor: [1, 2, 3].contains(pageVideoController.pageVideoTabIndex.value) ? null : Colors.transparent,
foregroundColor: [1, 2, 3].contains(pageVideoController.pageVideoTabIndex.value) ? Colors.black : Colors.white,
titleSpacing: 1.0,
leading: Obx(() => IconButton(icon: Icon(Icons.menu, color: tabColor(),), onPressed: () {},),),
title: Obx(() {
return TabBar(
controller: tabController,
tabs: pageTabs.map((v) => Tab(text: v)).toList(),
isScrollable: true,
tabAlignment: TabAlignment.center,
overlayColor: MaterialStateProperty.all(Colors.transparent),
unselectedLabelColor: unselectedTabColor(),
labelColor: tabColor(),
indicatorColor: tabColor(),
indicatorSize: TabBarIndicatorSize.label,
unselectedLabelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei'),
labelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.w600),
dividerHeight: 0,
labelPadding: const EdgeInsets.symmetric(horizontal: 10.0),
indicatorPadding: const EdgeInsets.symmetric(horizontal: 5.0),
onTap: (index) {
pageVideoController.updatePageVideoTabIndex(index); // 更新索引
pageController.jumpToPage(index);
},
);
}),
actions: [
Obx(() => IconButton(icon: Icon(Icons.search, color: tabColor(),), onPressed: () {},),),
],
),
body: Column(
children: [
Expanded(
child: Stack(
children: [
/// 水平滚动模块
PageView(
// 自定义滚动行为(支持桌面端滑动、去掉滚动条槽)
scrollBehavior: PageScrollBehavior().copyWith(scrollbars: false),
scrollDirection: Axis.horizontal,
controller: pageController,
onPageChanged: (index) {
pageVideoController.updatePageVideoTabIndex(index); // 更新索引
setState(() {
tabController.animateTo(index);
});
},
children: [
...pageModules
],
),
],
),
),
],
),
);

004360截图20240323232207685.png


005360截图20240323232239197.png


006360截图20240323232403006.png


007360截图20240323232536923.png


008360截图20240323232606453.png


flutter实现直播功能


c509198999b1463f82ebd4f45b61e0bc_1289798-20240324112649229-654344304.png


// 商品购买动效
Container(
...
),

// 加入直播间动效
const AnimationLiveJoin(
joinQueryList: [
{'avatar': 'assets/images/logo.png', 'name': 'andy'},
{'avatar': 'assets/images/logo.png', 'name': 'jack'},
{'avatar': 'assets/images/logo.png', 'name': '一条咸鱼'},
{'avatar': 'assets/images/logo.png', 'name': '四季平安'},
{'avatar': 'assets/images/logo.png', 'name': '叶子'},
],
),

// 送礼物动效
const AnimationLiveGift(
giftQueryList: [
{'label': '小心心', 'gift': 'assets/images/gift/gift1.png', 'user': 'Jack', 'avatar': 'assets/images/avatar/uimg2.jpg', 'num': 12},
{'label': '棒棒糖', 'gift': 'assets/images/gift/gift2.png', 'user': 'Andy', 'avatar': 'assets/images/avatar/uimg6.jpg', 'num': 36},
{'label': '大啤酒', 'gift': 'assets/images/gift/gift3.png', 'user': '一条咸鱼', 'avatar': 'assets/images/avatar/uimg1.jpg', 'num': 162},
{'label': '人气票', 'gift': 'assets/images/gift/gift4.png', 'user': 'Flower', 'avatar': 'assets/images/avatar/uimg5.jpg', 'num': 57},
{'label': '鲜花', 'gift': 'assets/images/gift/gift5.png', 'user': '四季平安', 'avatar': 'assets/images/avatar/uimg3.jpg', 'num': 6},
{'label': '捏捏小脸', 'gift': 'assets/images/gift/gift6.png', 'user': 'Alice', 'avatar': 'assets/images/avatar/uimg4.jpg', 'num': 28},
{'label': '你真好看', 'gift': 'assets/images/gift/gift7.png', 'user': '叶子', 'avatar': 'assets/images/avatar/uimg7.jpg', 'num': 95},
{'label': '亲吻', 'gift': 'assets/images/gift/gift8.png', 'user': 'YOYO', 'avatar': 'assets/images/avatar/uimg8.jpg', 'num': 11},
{'label': '玫瑰', 'gift': 'assets/images/gift/gift12.png', 'user': '宇辉', 'avatar': 'assets/images/avatar/uimg9.jpg', 'num': 3},
{'label': '私人飞机', 'gift': 'assets/images/gift/gift16.png', 'user': 'Hison', 'avatar': 'assets/images/avatar/uimg10.jpg', 'num': 273},
],
),

// 直播弹幕+商品讲解
Container(
margin: const EdgeInsets.only(top: 7.0),
height: 200.0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: liveJson[index]['message']?.length,
itemBuilder: (context, i) => danmuList(liveJson[index]['message'])[i],
),
),
SizedBox(
width: isVisibleGoodsTalk ? 7 : 35,
),
// 商品讲解
Visibility(
visible: isVisibleGoodsTalk,
child: Column(
...
),
),
],
),
),

// 底部工具栏
Container(
margin: const EdgeInsets.only(top: 7.0),
child: Row(
...
),
),

image.png


flutter直播通过 SlideTransition 组件实现直播进场动画。


return SlideTransition(
position: animationFirst ? animation : animationMix,
child: Container(
alignment: Alignment.centerLeft,
margin: const EdgeInsets.only(top: 7.0),
padding: const EdgeInsets.symmetric(horizontal: 7.0,),
height: 23.0,
width: 250,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0xFF6301FF), Colors.transparent
],
),
borderRadius: BorderRadius.horizontal(left: Radius.circular(10.0)),
),
child: joinList!.isNotEmpty ?
Text('欢迎 ${joinList![0]['name']} 加入直播间', style: const TextStyle(color: Colors.white, fontSize: 14.0,),)
:
Container()
,
),
);

class _AnimationLiveJoinState extends State<AnimationLiveJoin> with TickerProviderStateMixin {
// 动画控制器
late AnimationController controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500), // 第一个动画持续时间
);
late AnimationController controllerMix = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000), // 第二个动画持续时间
);
// 动画
late Animation<Offset> animation = Tween(begin: const Offset(2.5, 0), end: const Offset(0, 0)).animate(controller);
late Animation<Offset> animationMix = Tween(begin: const Offset(0, 0), end: const Offset(-2.5, 0)).animate(controllerMix);

Timer? timer;
// 是否第一个动画
bool animationFirst = true;
// 是否空闲
bool idle = true;
// 加入直播间数据列表
List? joinList;

@override
void initState() {
super.initState();

joinList = widget.joinQueryList!.toList();

runAnimation();
animation.addListener(() {
if(animation.status == AnimationStatus.forward) {
debugPrint('第一个动画进行中');
idle = false;
setState(() {});
}else if(animation.status == AnimationStatus.completed) {
debugPrint('第一个动画结束');
animationFirst = false;
if(controllerMix.isCompleted || controllerMix.isDismissed) {
timer = Timer(const Duration(seconds: 2), () {
controllerMix.forward();
debugPrint('第二个动画开始');
});
}
setState(() {});
}
});
animationMix.addListener(() {
if(animationMix.status == AnimationStatus.forward) {
setState(() {});
}else if(animationMix.status == AnimationStatus.completed) {
animationFirst = true;
controller.reset();
controllerMix.reset();
if(joinList!.isNotEmpty) {
joinList!.removeAt(0);
}
idle = true;
// 执行下一个数据
runAnimation();
setState(() {});
}
});
}

void runAnimation() {
if(joinList!.isNotEmpty) {
// 空闲状态才能执行,防止添加数据播放状态混淆
if(idle == true) {
if(controller.isCompleted || controller.isDismissed) {
setState(() {});
timer = Timer(Duration.zero, () {
controller.forward();
});
}
}
}
}

@override
void dispose() {
controller.dispose();
controllerMix.dispose();
timer?.cancel();
super.dispose();
}

}

以上只是介绍了一部分知识点,限于篇幅就先介绍这么多,希望有所帮助~
juejin.cn/post/731918…


n.sohucs.gif


作者:xiaoyan2015
来源:juejin.cn/post/7349542148733960211
收起阅读 »

用Flutter写可以,但架构可不能少啊

一个平台语言的开发优秀与否,取决于两个维度,一是语言的设计,这是语言天然的优劣,另一个测试程序员。后者决定的东西太多太多了,如果后者对于某个平台类型的语言开发使用不当,那将导致非常严重的后果,屎山的形成、开发排期的无限增大、稳定性差到太平洋等等问题。我之前写过...
继续阅读 »

一个平台语言的开发优秀与否,取决于两个维度,一是语言的设计,这是语言天然的优劣,另一个测试程序员。

后者决定的东西太多太多了,如果后者对于某个平台类型的语言开发使用不当,那将导致非常严重的后果,屎山的形成、开发排期的无限增大、稳定性差到太平洋等等问题。

我之前写过一个Fluter的项目,但是写时Flutter还没有发布正式版本,到今天Flutter已经成为一棵参天大树,无数的同僚前辈已经用Flutter密谋生计。这两天看了一下相关的语法、技术, 决定对其进行二次熟悉。

从哪方面入手,成了我的第一个问题,看文档?记不住,看视频? 没时间,做项目?没需求(相关的)。所以决定探究一下开篇的问题,如何在新语言领域做好开发。

进来我一直在关注架构方面的技术,到没想着成为架构师(因为我太菜),只是想成为一个懂点架构的程序员,让自己的代码有良好的扩展性、维护性、可读性、健壮性,以此来洗涤自我心灵,让自己每天过的舒服点,因为好的代码看起来确实会让人心情愉悦,让领导喜笑颜开,让钱包增厚那么一奶奶。

一、 常见的Flutter 架构模式

其实还是老生常谈的几个问题,最终的目的就是: “高内聚,低耦合”,满足这个条件 让程序运行就可以了

Fluter中常见的架构模式有以下几种:

  1. MVC(Model-View-Controller): 这是一种传统的软件设计架构,将应用程序分为模型(Model)、视图(View)和控制器(Controller)三个部分。在 Flutter 中,你可以使用类似于 StatefulWidgetState 和其他 Dart 类来实现 MVC 架构。
  2. MVVM(Model-View-ViewModel): MVVM 是一种流行的设计模式,将视图(View)、模型(Model)和视图模型(ViewModel)分离。在 Flutter 中,你可以使用类似于 ProviderGetXRiverpod 等状态管理库来实现 MVVM 架构。
  3. Bloc(Business Logic Component): Bloc 是一种基于事件驱动的架构,用于管理应用程序的业务逻辑和状态。它将应用程序分为视图、状态和事件三个部分,并使用流(Stream)来处理数据流。Flutter 官方推荐使用 flutter_bloc 库来实现 Bloc 架构。
  4. Redux: Redux 是一种状态管理模式,最初是为 Web 应用程序设计的,但也可以在 Flutter 中使用。它通过单一不可变的状态树来管理应用程序的状态,并使用纯函数来处理状态变化。在 Flutter 中,你可以使用 flutter_redux 或 provider 与 redux 库结合使用来实现 Redux 架构。
  5. GetX: GetX 是一个轻量级的、高性能的状态管理和路由导航库,它提供了一个全面的解决方案,包括状态管理、依赖注入、路由导航等。GetX 非常适合中小型 Flutter 应用程序的开发,可以减少代码量并提高开发效率。

当然MVP也不是不行。

对于Flutter来讲不仅有熟悉的MXXX, 还有几种新的模式。今天就先从最简单的MVC模式开始探究。

二、MVC架构实现Flutter开发

什么是MVC这里简单复习一下:

MVC(Model-View-Controller)是一种软件设计架构,用于将应用程序分为三个主要组件:模型(Model)、视图(View)和控制器(Controller)。这种架构的目的是将应用程序的逻辑部分与用户界面部分分离,以便于管理和维护。

以下是 MVC 架构中各组件的功能和作用:

  1. 模型(Model): 模型是应用程序的数据和业务逻辑部分。它负责管理数据的状态和行为,并提供对数据的操作接口。模型通常包括数据存储、数据验证、数据处理等功能。模型与视图和控制器相互独立,不直接与用户界面交互。
  2. 视图(View): 视图是应用程序的用户界面部分,负责向用户展示数据和接收用户输入。视图通常包括界面布局、样式设计、用户交互等功能。视图与模型和控制器相互独立,不直接与数据交互。
  3. 控制器(Controller): 控制器是模型和视图之间的中介,负责处理用户输入和更新模型数据。它接收用户的操作请求,并根据需要调用模型的方法来执行相应的业务逻辑,然后更新视图以反映数据的变化。控制器与模型和视图都有联系,但它们之间不直接通信。

在Flutter中 M无关紧要,只需要参与整个逻辑,让代码统一就可以了,封装一个对应的base,管理释放资源啊 公共数据也是可以的。

2.1 设计base

首先使用命令在Flutter项目中创建一个base, 创建时按照Flutter的工程类型做好组件的职责选择: Flutter工程中,通常有以下几种工程类型,下面分别简单概述下:
1. Flutter Application
标准的Flutter App工程,包含标准的Dart层与Native平台层
2. Flutter Module
Flutter组件工程,仅包含Dart层实现,Native平台层子工程为通过Flutter自动生成的隐藏工程
3. Flutter Plugin
Flutter平台插件工程,包含Dart层与Native平台层的实现
4. Flutter Package
Flutter纯Dart插件工程,仅包含Dart层的实现,往往定义一些公共Widget

很明显 我们需要的base 创建为package 即可:

 flutter create -t package base 

然后在项目的pubspec.yaml 中的

dependencies:
flutter:
sdk: flutter
base: //此处添加配置
path: ../base
  • base 结构

image.png

View部分按照Flutter的常用开发模式(可变状态组件)设计为state + view 组合成View

他们的关系如下图:

image.png

代码:

2.1.1 base 代码
  1. model;
abstract class MvcBaseModel {
void dispose();
}
  1. controller
abstract class MvcBaseController {
late M _model;
final _dataUpdatedController = StreamController.broadcast();

MvcBaseController() {
_model = createModel();
}

void updateData(M model) {
_dataUpdatedController.add(model);
}

M createModel();

StreamController get streamController => _dataUpdatedController;

M get model => _model;
}
  1. view. (view)
abstract class MvcBaseView extends MvcBaseController> extends StatefulWidget {
final C controller;

const MvcBaseView({Key? key, required this.controller});

@override
State<StatefulWidget> createState() {
print("create state ${controller.streamController == null}");
MvcBaseState mvcBaseState = create();
mvcBaseState.createStreamController(controller.streamController);
return mvcBaseState;
}

MvcBaseState create();
}
  1. view(state)
abstract class MvcBaseState extends MvcBaseModel, T extends StatefulWidget>
extends State<T> {
late StreamController<M> streamController;
late StreamSubscription<M> _streamSubscription;


@override
Widget build(BuildContext context);

@override
void initState() {
super.initState();
print("init state");
_streamSubscription = this.streamController.stream.listen((event) {
setState(() {
observer(event);
});
});
}

void createStreamController(StreamController<M> streamController) => this.streamController = streamController;

void observer(M event);

@override
void dispose() {
_streamSubscription.cancel();
streamController.close();
super.dispose();
}
}

三、使用Demo

  1. 入口:

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(primarySwatch: Colors.blue),
home: CounterView(controller: CounterController()),
);
}
}
  1. view + state

class CounterView extends MvcBaseView<CounterController> {
const CounterView({super.key,required CounterController controller})
: super(controller: controller);

@override
MvcBaseState<MvcBaseModel, StatefulWidget> create() => _CounterViewState();
}

class _CounterViewState extends MvcBaseState<CounterModel, CounterView> {
var count = 0;

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Counter App (MVC)'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const
Text(
'Counter Value = :',
style: TextStyle(fontSize: 20),
),
Text(
'${count}',
style: const TextStyle(fontSize: 50, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
debugPrint("---11-->");
// setState(() {
widget.controller.incrementCounter();
// });
},
child: Text('Test Add111'))
],
),
),
);
}

@override
void observer(CounterModel event) {
count = event.counter;
}
}
  1. model
class CounterModel extends MvcBaseModel{
int _counter = 0;

int get counter => _counter;

increment() {
_counter++;
}

@override
void dispose() {
}
}
  1. controller

class CounterController extends MvcBaseController {
@override
CounterModel createModel() => CounterModel();

void incrementCounter() {
model.increment();
updateData(model);
}

int get counter => model.counter;
}

四: 总结

通过实现基于MVC架构的Flutter应用程序,我们可以看到以下几点:

  1. 模型(Model)的作用: 模型负责管理应用程序的数据状态和行为。在我们的示例中,CounterModel负责管理计数器的数值状态,并提供了增加计数器数值的方法。
  2. 控制器(Controller)的作用: 控制器是模型和视图之间的中介,负责处理用户输入并更新模型数据。在示例中,CounterController接收用户点击事件,并调用CounterModel的方法来增加计数器数值,然后通知视图更新数据。
  3. 视图(View)的作用: 视图是应用程序的用户界面部分,负责向用户展示数据和接收用户输入。在示例中,CounterView负责展示计数器的数值,并提供了一个按钮来触发增加计数器数值的操作。
  4. MVC架构的优势: MVC架构能够将应用程序的逻辑部分与用户界面部分分离,使得代码结构更清晰,易于维护和扩展。通过单独管理模型、视图和控制器,我们可以更好地组织代码,并实现高内聚、低耦合的设计原则。
  5. 基础组件的设计: 我们设计了一个基础组件库,包括模型(MvcBaseModel)、控制器(MvcBaseController)、视图(MvcBaseView)和视图状态(MvcBaseState)。这些基础组件可以帮助我们快速构建符合MVC架构的Flutter应用程序,并实现模块化、可复用的代码结构。

通过理解和应用MVC架构,我们可以更好地组织和管理Flutter应用程序的代码,提高代码质量和开发效率。同时,我们也可以通过学习和探索其他架构模式,如MVVM、Bloc、Redux等,来丰富我们的架构设计思路,进一步提升应用程序的性能和用户体验。

后续将探索MVVM等其他架构模式。

github.com/kongxiaoan/… 源码地址


作者:麦客奥德彪
来源:juejin.cn/post/7366557738266558498
收起阅读 »

基于Flutter实现的小说阅读器——BITReader ,相信我你也可以变成光!

前言 最近感觉自己有点颓废,左思右想后觉得不能这样浪费时间,天天来摆烂。受到了群友的激励以及最近自己喜欢看小说。就想我能不能自己也做一款小说阅读器出来呢。在最开始的时候花了一段时间写了一个版本。当时用的是一个开源的接口,当我写好后使用了两天接口挂了我就只有大眼...
继续阅读 »
6d95f5df68248bb55b5b97b4502332711ff7d073.png@2560w_400h_100q_1o.webp

前言


最近感觉自己有点颓废,左思右想后觉得不能这样浪费时间,天天来摆烂。受到了群友的激励以及最近自己喜欢看小说。就想我能不能自己也做一款小说阅读器出来呢。在最开始的时候花了一段时间写了一个版本。当时用的是一个开源的接口,当我写好后使用了两天接口挂了我就只有大眼瞪小眼了。之后在 FlutterCandies里面咨询了群友,发现了一种使用外部提供书籍数据源的方法可以避免数据来源挂掉,说干就干vscode启动!




项目地址


github.com/fluttercand…


项目介绍


BITReader是一款基于Flutter实现的小说阅读器


当前功能包含:



  • 源搜索:使用内置数据来源进行搜索数据(后续更新:用户可以自行导入来源进行源搜索

  • 收藏书架

  • 阅读历史记录

  • 阅读设置:字号设置,字体颜色更改,自定义阅读背景(支持调色板自定义选择,支持image设置为背景

  • 主题设置:支持九种颜色的主题样式

  • 书籍详情:展示书籍信息以及章节目录等书籍信息




支持平台


平台是否支持
Android
IOS
Windows
MacOS
Web
Linux

项目截图


729_1x_shots_so.png
360_1x_shots_so.png
57_1x_shots_so.png
300_1x_shots_so.png
402_1x_shots_so.png

mac运行截图


CE7D99422AA2804700F33FC94D273EC7.png

windows运行截图


d7a40aa1-1572-4969-9d78-55d2abcd791b.png

项目结构


lib
├── main.dart -- 入口
├── assets -- 本地资源生成
├── base -- 请求状态、页面状态
├── db -- 数据缓存
├── icons -- 图标
├── net -- 网络请求、网络状态
├── n_pages
├── detail -- 详情页
├── home -- 首页
├── search -- 全网搜索搜索页
├── history -- 历史记录
├── read -- 小说阅读
└── like -- 收藏书架
├── pages 已废弃⚠
├── home -- 首页
├── novel -- 小说阅读
├── search -- 全网搜索
├── category -- 小说分类
├── detail_novel -- 小说详情
├── book_novel -- 书架、站源
└── collect_novel -- 小说收藏
├── route -- 路由
└── theme -- 主题管理
└── themes -- 主题颜色-9种颜色
├── tools -- 工具类 、解析工具、日志、防抖。。。
└── widget -- 自定义组件、工具 、加载、状态、图片 等。。。。。。

阅读器主要包含的模块



  • 阅读显示:文本解析,对文本进行展示处理

  • 数据解析: 数据源的解析,以及数据来源的解析(目前只支持简单数据源格式解析、后续可能会更新更多格式解析

  • 功能:阅读翻页样式、字号、背景、背景图、切换章节、收藏、历史记录、本地缓存等


阅读显示


阅读文本展示我用的是extended_text因为支持自定义效果很好。


实现的效果把文本中 “ ” 引用起来的文本自定义成我自己想要的效果样式。


class MateText extends SpecialText {
MateText(
TextStyle? textStyle,
SpecialTextGestureTapCallback? onTap, {
this.showAtBackground = false,
required this.start,
required this.color,
}) : super(flag, '”', textStyle, onTap: onTap);
static const String flag = '“';
final int start;
final Color color;

/// whether show background for @somebody
final bool showAtBackground;

@override
InlineSpan finishText() {
final TextStyle textStyle =
this.textStyle?.copyWith(color: color) ?? const TextStyle();

final String atText = toString();

return showAtBackground
? BackgroundTextSpan(
background: Paint()..color = Colors.blue.withOpacity(0.15),
text: atText,
actualText: atText,
start: start,

///caret can move int0 special text
deleteAll: true,
style: textStyle,
recognizer: (TapGestureRecognizer()
..onTap = () {
if (onTap != null) {
onTap!(atText);
}
}))
: SpecialTextSpan(
text: atText,
actualText: atText,
start: start,
style: textStyle,
recognizer: (TapGestureRecognizer()
..onTap = () {
if (onTap != null) {
onTap!(atText);
}
}));
}
}


class NovelSpecialTextSpanBuilder extends SpecialTextSpanBuilder {
NovelSpecialTextSpanBuilder({required this.color});
Color color;
set setColor(Color c) => color = c;
@override
SpecialText? createSpecialText(String flag,
{TextStyle? textStyle,
SpecialTextGestureTapCallback? onTap,
int? index}) {
if (flag == '') {
return null;
} else if (isStart(flag, AtText.flag)) {
return AtText(
textStyle,
onTap,
start: index! - (AtText.flag.length - 1),
color: color,
);
} else if (isStart(flag, MateText.flag)) {
return MateText(
textStyle,
onTap,
start: index! - (MateText.flag.length - 1),
color: color,
);
}
// index is end index of start flag, so text start index should be index-(flag.length-1)
return null;
}
}

数据解析编码格式转换


首先数据是有不同的编码格式,否则我们直接展示可能会导致乱码问题。
先把数据给根据查找到的编码类型来做单独的处理转换。


/// 解析html数据 解码 不同编码
static String parseHtmlDecode(dynamic htmlData) {
String resultData = gbk.decode(htmlData);
final charset = ParseSourceRule.parseCharset(htmlData: resultData) ?? "gbk";
if (charset.toLowerCase() == "utf-8" || charset.toLowerCase() == "utf8") {
resultData = utf8.decode(htmlData);
}
return resultData;
}

 static String? parseCharset({
required String htmlData,
}) {
Document document = parse(htmlData);

List<Element> metaTags = document.getElementsByTagName('meta').toList();
for (Element meta in metaTags) {
String? charset = meta.attributes['charset'];
String content = meta.attributes['content'] ??
""; //<meta http-equiv="Content-Type" content="text/html; charset=utf-8">

if (charset != null) {
return charset;
}
List<String> parts = content.split(';');
for (String part in parts) {
part = part.trim();
if (part.startsWith('charset=')) {
return part.split('=').last.trim();
}
}
}

return null;
}

数据结构解析-代码太多只展示部分


Document document = parse(htmlData);

//
List<Element> rootNodes = [];
if (rootSelector != null && rootSelector.isNotEmpty) {
//
List<String> rootParts = rootSelector.split(RegExp(r'[@>]'));
String initialPart = rootParts[0].trim();

//
if (initialPart.startsWith('class.')) {
String className = initialPart.split('.')[1];
rootNodes = document.getElementsByClassName(className).toList();
} else if (initialPart.startsWith('.')) {
String className = initialPart.substring(1);
rootNodes = document.getElementsByClassName(className).toList();
} else if (initialPart.startsWith('#')) {
String idSelector = initialPart.substring(1);
rootNodes = document.querySelectorAll('#$idSelector').toList();
} else if (initialPart.startsWith('id.')) {
String idSelector = initialPart.split('.')[1];
var element = document.querySelector('#$idSelector');
if (element != null) {
rootNodes.add(element);
}
} else if (initialPart.contains(' ')) {
String idSelector = initialPart.replaceAll(' ', ">");
var element = document.querySelector(idSelector);
if (element != null) {
rootNodes.add(element);
}
} else {
rootNodes = document.getElementsByTagName(initialPart).toList();
}

存储工具类 - 部分代码


/// shared_preferences
class PreferencesDB {
PreferencesDB._();
static final PreferencesDB instance = PreferencesDB._();
SharedPreferencesAsync? _instance;
SharedPreferencesAsync get sps => _instance ??= SharedPreferencesAsync();

/*** APP相关 ***/

/// 主题外观模式
///
/// system(默认):跟随系统 light:普通 dark:深色
static const appThemeDarkMode = 'appThemeDarkMode';

/// 多主题模式
///
/// default(默认)
static const appMultipleThemesMode = 'appMultipleThemesMode';

/// 字体大小
///
///
static const fontSize = 'fontSize';

/// 字体粗细
static const fontWeight = 'fontWeight';

/// 设置-主题外观模式
Future<void> setAppThemeDarkMode(ThemeMode themeMode) async {
await sps.setString(appThemeDarkMode, themeMode.name);
}

/// 获取-主题外观模式
Future<ThemeMode> getAppThemeDarkMode() async {
final String themeDarkMode =
await sps.getString(appThemeDarkMode) ?? 'system';
return darkThemeMode(themeDarkMode);
}

/// 设置-多主题模式
Future<void> setMultipleThemesMode(String value) async {
await sps.setString(appMultipleThemesMode, value);
}

/// 获取-多主题模式
Future<String> getMultipleThemesMode() async {
return await sps.getString(appMultipleThemesMode) ?? 'default';
}

/// 获取-fontsize 大小 默认18
Future<double> getNovelFontSize() async {
return await sps.getDouble(fontSize) ?? 18;
}

/// 设置 -fontsize 大小
Future<void> setNovelFontSize(double size) async {
await sps.setDouble(fontSize, size);
}

/// 设置-多主题模式
Future<void> setNovelFontWeight(NovelReadFontWeightEnum value) async {
await sps.setString(fontWeight, value.id);
}

/// 获取-多主题模式
Future<String> getNovelFontWeight() async {
return await sps.getString(fontWeight) ?? 'w300';
}
}

最后


特别鸣谢FlutterCandies糖果社区,也欢迎加入我们的大家庭。让我们一起学习共同进步


免责声明:本项目提供的源代码仅用学习,请勿用于商业盈利。


作者:7_bit
来源:juejin.cn/post/7433306628994940979
收起阅读 »

对于 Flutter 快速开发框架的思考

要打造一个Flutter的快速开发框架,首先要思考的事情是一个快速开发框架需要照顾到哪些功能点,经过2天的思考,我大致整理了一下需要的能力: 状态管理:很明显全局状态管理是不可或缺的,这个在前端领域上,几乎是一种不容置疑的方案沉淀,他就像人体的血液循环系统,...
继续阅读 »

loading


要打造一个Flutter的快速开发框架,首先要思考的事情是一个快速开发框架需要照顾到哪些功能点,经过2天的思考,我大致整理了一下需要的能力:



  • 状态管理:很明显全局状态管理是不可或缺的,这个在前端领域上,几乎是一种不容置疑的方案沉淀,他就像人体的血液循环系统,连接了每个区域角落。

  • 网络请求管理:这个是标配了,对外的窗口,一般来讲做选型上需要注意可以支持请求拦截,支持响应拦截,以及错误处理机制,方便做重试等等。

  • 路由管理:可以说很多项目路由混乱不堪,导致难以维护,和这个功能脱不了干系,一般来讲,需要支持到页面参数传递,路由守卫的能力。

  • UI组件库:在Flutter上,可能不太需要考虑这个,因为Flutter本身自己就是已这个为利刃的行家了,不过现在有些企业发布了自己的UI库,觉得可以跟一下。

  • 数据持久化:对于用户的一些设置,个性化配置,通常需要存在本地。而且,有时候,我们在做性能优化的时候,需要缓存网络请求到本地,以便,可以实现秒开页面,因此这依然也是一个不可获取的基础模块。

  • 依赖注入:很多情况下,为了便于管理和使用应用中的服务和数据模型,我们需要这个高级能力,但是属于偏高级点的能力了,所以是一个optional的,你可以不考虑。

  • 国际化:支持多语言开发,现在App一般都还是挺注重这块的,而且最好是立项的时候就考虑进来,为后续的出海做准备,因为这个越到后面,处理起来工作量越大。

  • 测试框架:支持单元测试、组件测试和集成测试,保证业务质量,自动化发现问题。

  • 调试工具:帮助开发者快速定位和解决问题,排查性能问题。

  • CI/CD集成:支持持续集成和持续部署的解决方案,简化应用的构建、测试和发布过程。


那么,基于上面的分析,我就开始做了一些选型,这里基本上就是按照官方Flutter Favorites ,里面推荐的来选了。因为这些建议的库都是目前Flutter社区中比较流行和受欢迎的,能够提供稳定和高效的开发体验。


1. 状态管理:Riverpod


loading



  • 库名: flutter_riverpod

  • 描述: 一个提供编译时安全、测试友好和易于组合的状态管理库。

  • 选择理由: Riverpod 是 Provider 的升级版,提供更好的性能和灵活性,但是说哪个更好,其实不能一概而论,毕竟不同的人会有不同的编码习惯,当然这里可以设计得灵活一些,具体全局状态管理可以替换,即便你想使用 GetX,或者是 flutter_bloc 也是 OK 的。


    @riverpod
    Future boredSuggestion(BoredSuggestionRef ref) async {
    final response = await http.get(
    Uri.https('boredapi.com/api/activit…'),
    );
    final json = jsonDecode(response.body);
    return json['activity']! as String;
    }


    class Home extends ConsumerWidget {
    @override
    Widget build(BuildContext context, WidgetRef ref) {
    final boredSuggestion = ref.watch(boredSuggestionProvider);
    // Perform a switch-case on the result to handle loading/error states
    return boredSuggestion.when(
    loading: () => Text('loading'),
    error: (error, stackTrace) => Text('error: $error'),
    data: (data) => Text(data),
    );
    }
    }



2. 网络请求管理:Dio



  • 库名: dio

  • 描述: 一个强大的Dart HTTP客户端,支持拦截器、全局配置、FormData、请求取消等。

  • 选择理由: Dio 支持Restful API、拦截器和全局配置,易于扩展和维护。这个已经是老牌的网络请求库了,稳定的很,且支持流式传输,访问大模型也丝毫不马虎。


    final rs = await dio.get(
    url,
    options: Options(responseType: ResponseType.stream), // Set the response type to stream.
    );
    print(rs.data.stream); // Response stream.



3. 路由管理:routemaster



  • 库名: routemaster

  • 描述: 提供声明式路由解决方案,支持参数传递、路由守卫等。

  • 选择理由: url的方式访问,简化了路由管理的复杂度。


    '/protected-route': (route) =>
    canUserAccessPage()
    ? MaterialPage(child: ProtectedPage())
    : Redirect('/no-access'),



4. UI组件库:tdesign_flutter



  • 库名: tdesign_flutter

  • 描述: 腾讯TDesign Flutter技术栈组件库,适合在移动端项目中使用。。

  • 选择理由: 样式比原生的稍微好看且统一一些,大厂维护,减少一些在构建UI方面的复杂性。


5. 数据持久化:Hive


loading



  • 库名: hive

  • 描述: 轻量级且高性能的键值对数据库。

  • 选择理由: Hive 提供了高性能的读写操作,无需使用SQL即可存储对象。


    var box = Hive.box('myBox');


    box.put('name', 'David');


    var name = box.get('name');


    print('Name: $name');



6. 依赖注入:GetIt



  • 库名: get_it

  • 描述: 一个简单的服务注入,用于依赖注入。

  • 选择理由: GetIt 提供了灵活的依赖注入方式,易于使用且性能高效。


    final getIt = GetIt.instance;


    void setup() {
    getIt.registerSingleton(AppModel());


    // Alternatively you could write it if you don't like global variables
    GetIt.I.registerSingleton(AppModel());
    }


    MaterialButton(
    child: Text("Update"),
    onPressed: getIt().update // given that your AppModel has a method update
    ),



7. 国际化和本地化:flutter_localization



  • 库名: flutter_localization

  • 描述: Flutter官方提供的国际化和本地化支持。

  • 选择理由: 官方支持,集成简单,覆盖多种语言。


8. 测试和调试:flutter_test, mockito



  • 库名: flutter_test (内置), mockito

  • 描述: flutter_test提供了丰富的测试功能,mockito用于模拟依赖。

  • 选择理由: flutter_test是Flutter的官方测试库,mockito可以有效地模拟类和测试行为。


9. 日志系统:logger



  • 库名: logger

  • 描述: 提供简单而美观的日志输出。

  • 选择理由: logger支持不同级别的日志,并且输出格式清晰、美观。


10. CI/CD集成


CI/CD集成通常涉及外部服务,如GitHub Actions、Codemagic等,而非Flutter库。


目录规划


前面已经做完了选型,下来我们可以确立一下我们快速开发框架的目录结构,我们给框架取名为fdflutter,顾名思义,就是fast development flutter,如下:


fdflutter/
├── lib/
│ ├── core/
│ │ ├── api/
│ │ │ └── api_service.dart
│ │ ├── di/
│ │ │ └── injection_container.dart
│ │ ├── localization/
│ │ │ └── localization_service.dart
│ │ ├── routing/
│ │ │ └── router.dart
│ │ └── utils/
│ │ └── logger.dart
│ ├── data/
│ │ ├── datasources/
│ │ │ ├── local_datasource.dart
│ │ │ └── remote_datasource.dart
│ │ └── repositories/
│ │ └── example_repository.dart
│ ├── domain/
│ │ ├── entities/
│ │ │ └── example_entity.dart
│ │ └── usecases/
│ │ └── get_example_data.dart
│ ├── presentation/
│ │ ├── pages/
│ │ │ └── example_page.dart
│ │ └── providers/
│ │ └── example_provider.dart
│ └── main.dart
├── test/
│ ├── data/
│ ├── domain/
│ └── presentation/
├── pubspec.yaml
└── README.md


在这个结构中,我保持了核心功能、数据层、领域层和表示层的划分:



  • core/api/: 使用Dio来实现ApiService,处理所有网络请求。

  • core/di/: 使用GetIt来实现依赖注入,注册和获取依赖。

  • core/localization/: 使用flutter_localization来实现本地化服务。

  • core/routing/: 使用routemaster来实现路由管理。

  • core/utils/: 使用logger来实现日志记录。

  • data/: 数据层包含数据源和仓库,用于获取和管理数据。

  • domain/: 领域层包含实体和用例,用于实现业务逻辑。

  • presentation/: 表示层包含页面和Provider,用于显示UI和管理状态。

  • test/: 测试目录包含各层的测试代码,使用flutter_test和mockito来编写测试。


我想,感兴趣的朋友们,可以私信我交流,我后续会在 GitHub 上放出该flutter 快速开发框架的 template 地址。


探索代码的无限可能,与老码小张一起开启技术之旅。点关注,未来已来,每一步深入都不孤单。



作者:brzhang
来源:juejin.cn/post/7340898858556964864
收起阅读 »

One vs Taro vs Uniapp:跨平台三巨头对决,谁能成为你的终极开发利器?

随着移动端和Web应用的多样化发展,跨平台开发已经成为越来越多开发者的选择。写一套代码,运行在多个平台上,能大大提升开发效率、节省时间。那么,问题来了:在众多的跨平台框架中,究竟该选择哪个?今天在 GitHub 上看到了一个新的多端框架,ONE,号称可以统一全...
继续阅读 »

随着移动端和Web应用的多样化发展,跨平台开发已经成为越来越多开发者的选择。写一套代码,运行在多个平台上,能大大提升开发效率、节省时间。那么,问题来了:在众多的跨平台框架中,究竟该选择哪个?今天在 GitHub 上看到了一个新的多端框架,ONE,号称可以统一全平台



索性,我们就来聊聊三个热门框架——TaroOneUniapp,看看它们各自的优势和适用场景,帮你找到最适合的跨平台解决方案。


为什么选择Taro、One和Uniapp?


这三者都是当前跨平台开发领域的主力军,但它们各自的定位和优势略有不同。Taro,由京东旗下的凹凸实验室推出,基于React,特别擅长小程序和H5的跨平台开发,国内开发者使用率很高;One,作为一款新兴的React框架,专注于Web、移动端和桌面端的跨平台开发,且具备本地优先的数据同步特性;Uniapp,由DCloud开发,基于Vue,主打“一次开发,多端适配”,在国内的小程序开发中占有一席之地。


接下来,我们从多个维度对比一下它们,看看哪个框架更适合你的项目需求。


平台覆盖范围对比


Taro的最大特点是对小程序支持非常全面,不仅支持微信小程序,还兼容支付宝、百度、字节跳动等多种小程序平台。此外,它还支持H5和React Native开发,因此如果你需要同时开发多个小程序和移动端App,Taro是一个非常合适的选择。



docs.taro.zone/docs/


One在平台覆盖上更加广泛,它不仅支持Web、iOS、Android,还支持桌面应用程序的开发。然而,One目前并不支持小程序开发,所以如果你项目的重点是小程序,One可能不适合你。



onestack.dev/


Uniapp则也是小程序开发的强者,支持包括微信、支付宝、钉钉在内的多个小程序平台。同时,Uniapp还支持H5、iOS、Android,甚至可以打包为App、桌面应用,几乎覆盖了所有主流平台。对于那些需要开发多端应用,尤其是小程序的开发者来说,Uniapp可以说是一个“全能型选手”。



zh.uniapp.dcloud.io/


总结:如果你的项目主要涉及小程序开发,TaroUniapp更胜一筹,Taro在React生态下表现优异,Uniapp则在Vue生态中一骑绝尘;而如果你的项目重心是跨Web、移动端和桌面应用,One的优势更为明显。


技术栈对比——React vs Vue


框架选择的背后,往往与技术栈密不可分。对于大部分开发者来说,选择技术栈往往决定了上手的难度和开发的舒适度


Taro基于React,提供了类似React的开发体验。对于习惯React的开发者来说,Taro非常友好,语法、组件化思路与React保持一致,你可以毫无缝隙地把已有的React经验直接应用到Taro项目中。


One同样基于React,但它做到了更深层次的跨平台统一,支持Web、移动端和桌面端的无缝切换,并且主打本地优先的数据处理,避免了频繁的API调用和复杂的同步逻辑。如果你习惯了React,并且希望进一步简化跨平台开发中的数据处理,One会是一个非常强大的工具。


Uniapp则基于Vue,对于喜欢Vue的开发者来说,Uniapp的上手难度很低,而且Uniapp的语法风格与Vue保持高度一致,你可以直接复用已有的Vue项目中的代码和经验。


总结:喜欢React的开发者可以考虑TaroOne,两者在跨平台能力上各有侧重;而如果你偏好Vue,那么Uniapp无疑是更理想的选择。


跨平台代码复用率对比


在跨平台开发中,代码复用率是开发者最关心的问题。TaroOneUniapp在这方面的表现都有各自的亮点。


Taro的代码复用率相对高,尤其是在小程序和H5应用中,大部分代码可以共享。但如果涉及到React Native,你仍然需要做一些针对平台的适配工作。


One则走得更远,它通过React和本地优先的数据处理模式,最大程度地减少了跨平台开发中的代码分歧。你可以只写一套代码,就能让应用无缝运行在Web、移动端和桌面端,并且无需为离线数据同步操心,这让One的代码复用率和开发效率非常出色。


Uniapp在代码复用率上表现也非常不错,它支持“一次开发,多端适配”,通过Vue语法几乎可以覆盖所有平台。只需要根据不同平台的差异做少量适配,便能确保项目在多端无缝运行。


总结:如果你希望最大化代码复用率,One在Web、移动和桌面端的表现最优;而如果你需要同时兼顾小程序和H5、App开发,TaroUniapp都可以满足需求。


性能对比


TaroUniapp在小程序和H5上的性能表现都非常优秀,接近原生体验。在React Native和App开发中,Taro的性能也相对稳定。


One则主打性能无缝衔接,尤其是本地优先的特性让它在处理大量数据时能表现得更加流畅。相比Taro和Uniapp,One的Web和桌面端性能更为出色,移动端的性能也接近原生。


总结:在小程序领域,TaroUniapp表现优秀;而在处理跨平台的Web、移动和桌面应用时,One的性能表现更胜一筹。


代码示例——如何选择适合的框架


让我们通过一个简单的代码示例,看看Taro、One和Uniapp在实际开发中的差异。


Taro 代码示例


import { Component } from '@tarojs/taro';
import { ViewButton } from '@tarojs/components';

class TodoApp extends Component {
  state = {
    todos: []
  };

  addTodo = () => {
    this.setState({ todos: [...this.state.todos'新任务'] });
  };

  render() {
    return (
      <View>
        <Button onClick={this.addTodo}>添加任务</Button>
        <View>
          {this.state.todos.map((todo, index) => (
            <View key={index}>{todo}</View>
          ))}
        </View>
      </View>

    );
  }
}

One 代码示例


import { useLocalStore } from 'one-stack';

function TodoApp() {
  const [todos, setTodos] = useLocalStore('todos', []);

  function addTodo() {
    setTodos([...todos, '新任务']);
  }

  return (
    <div>
      <button onClick={addTodo}>添加任务</button>
      <ul>
        {todos.map((todo, index) => (
          <li key={index}>{todo}</li>
        ))}
      </ul>
    </div>

  );
}

Uniapp 代码示例


<template>
  <view>
    <button @click="addTodo">添加任务</button>
    <view v-for="(todo, index) in todos" :key="index">{{ todo }}</view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      todos: []
    };
  },
  methods: {
    addTodo() {
      this.todos.push('新任务');
    }
  }
};
</script>

可以看到,TaroUniapp在小程序和多端开发上拥有强大的兼容性,而One则在Web和桌面应用中拥有更广泛的适用场景。不同的框架在开发体验上虽然有所不同,但总体而言,它们都能够较好地实现跨平台开发的目标。


生态与社区支持


选择一个框架,不仅要看它本身的功能,还要看其背后的生态和社区支持,因为这些决定了在遇到问题时能否快速找到解决方案,以及框架的未来发展潜力。


Taro依托于京东的支持,经过多年的迭代更新,拥有一个非常活跃的社区。你可以在社区中找到丰富的插件、第三方组件库和详细的教程文档。如果你在小程序开发中遇到问题,基本上都能通过Taro的社区找到解决方案。


One虽然是一个新兴的框架,但它的开发团队对React社区有着深厚的积累。因为基于React,它可以无缝利用React的生态,包括丰富的第三方库、开发工具和强大的社区支持。不过,作为一个新框架,One的社区规模还不如Taro和Uniapp庞大,但由于其独特的跨平台能力,未来的生态成长潜力不容小觑。


Uniapp的社区在国内极其庞大,DCloud团队也在持续更新Uniapp的功能和插件库。它的文档详细而完善,社区中也有大量的开发者分享经验,解决实际开发中的问题,尤其是在小程序开发领域,Uniapp几乎拥有无可匹敌的生态优势。


总结:如果你注重社区和生态的完善性,TaroUniapp的社区非常活跃,拥有丰富的插件和第三方支持;而如果你追求跨平台开发的前沿技术,One虽然较新,但凭借React的生态也有着很强的社区支持潜力。


结论:如何选择适合你的跨平台开发框架?


在Taro、One和Uniapp三者之间,选择最适合的框架取决于你的项目需求和技术栈。



  • • 如果你以小程序开发为核心,并且希望使用React进行开发,那么Taro是你的最佳选择,尤其是当你还需要兼顾H5和移动端应用时,Taro的表现也非常出色。

  • • 如果你的项目涉及Web、移动端和桌面端的统一开发,并且你希望有更好的代码复用率和数据同步机制,那么One会是一个颠覆性的选择,它通过本地优先的设计,解决了许多跨平台开发中的数据同步问题,提升了开发效率。

  • • 如果你更习惯Vue,并且需要覆盖从小程序到H5、App等多个平台,Uniapp无疑是一个全能的选手。它在国内有着广泛的应用,特别是在小程序开发中拥有明显优势。


最终,选择哪一个框架,还是要根据你团队的技术栈项目需求以及你对跨平台性能和代码复用率的要求做出判断。无论是Taro、One还是Uniapp,它们都能为你的跨平台开发提供强大的支持。




希望这篇文章能帮你理清思路,让你在框架选择上不再迷茫。如果你还在犹豫,不妨亲自试用一下这三个框架,结合实际开发需求和团队技术背景,相信你一定能找到那个“最合拍”的开发工具。




你觉得这三个框架哪个更适合你的项目呢?有任何问题或者经验分享,欢迎在评论区留言,我们一起讨论交流!


作者:老码小张
来源:juejin.cn/post/7420971044158193664
收起阅读 »

接口不能对外暴露怎么办?

在业务开发的时候,经常会遇到某一个接口不能对外暴露,只能内网服务间调用的实际需求。 面对这样的情况,我们该如何实现呢? 1. 内外网接口微服务隔离 将对外暴露的接口和对内暴露的接口分别放到两个微服务上,一个服务里所有的接口均对外暴露,另一个服务的接口只能内网服...
继续阅读 »

在业务开发的时候,经常会遇到某一个接口不能对外暴露,只能内网服务间调用的实际需求。


面对这样的情况,我们该如何实现呢?


1. 内外网接口微服务隔离


将对外暴露的接口和对内暴露的接口分别放到两个微服务上,一个服务里所有的接口均对外暴露,另一个服务的接口只能内网服务间调用。


该方案需要额外编写一个只对内部暴露接口的微服务,将所有只能对内暴露的业务接口聚合到这个微服务里,通过这个聚合的微服务,分别去各个业务侧获取资源。


该方案,新增一个微服务做请求转发,增加了系统的复杂性,增大了调用耗时以及后期的维护成本。


2. 网关 + redis 实现白名单机制


在 redis 里维护一套接口白名单列表,外部请求到达网关时,从 redis 获取接口白名单,在白名单内的接口放行,反之拒绝掉。


该方案的好处是,对业务代码零侵入,只需要维护好白名单列表即可;


不足之处在于,白名单的维护是一个持续性投入的工作,在很多公司,业务开发无法直接触及到 redis,只能提工单申请,增加了开发成本;


另外,每次请求进来,都需要判断白名单,增加了系统响应耗时,考虑到正常情况下外部进来的请求大部分都是在白名单内的,只有极少数恶意请求才会被白名单机制所拦截,所以该方案的性价比很低。


3. 方案三 网关 + AOP


相比于方案二对接口进行白名单判断而言,方案三是对请求来源进行判断,并将该判断下沉到业务侧。避免了网关侧的逻辑判断,从而提升系统响应速度。


我们知道,外部进来的请求一定会经过网关再被分发到具体的业务侧,内部服务间的调用是不用走外部网关的(走 k8s 的 service)。


根据这个特点,我们可以对所有经过网关的请求的header里添加一个字段,业务侧接口收到请求后,判断header里是否有该字段,如果有,则说明该请求来自外部,没有,则属于内部服务的调用,再根据该接口是否属于内部接口来决定是否放行该请求。


该方案将内外网访问权限的处理分布到各个业务侧进行,消除了由网关来处理的系统性瓶颈;


同时,开发者可以在业务侧直接确定接口的内外网访问权限,提升开发效率的同时,增加了代码的可读性。


当然该方案会对业务代码有一定的侵入性,不过可以通过注解的形式,最大限度的降低这种侵入性。


图片


具体实操



下面就方案三,进行具体的代码演示。



首先在网关侧,需要对进来的请求header添加外网标识符: from=public


@Component
public class AuthFilter implements GlobalFilterOrdered {
    @Override
    public Mono < Void > filter ( ServerWebExchange exchange, GatewayFilterChain chain ) {
         return chain.filter(
         exchange.mutate().request(
         exchange.getRequest().mutate().header('id''').header('from''public').build())
         .build()
         );
    }

    @Override
    public int getOrder () {
        return 0;
    }
 }

接着,编写内外网访问权限判断的AOP和注解


@Aspect
@Component
@Slf4j
public class OnlyIntranetAccessAspect {
 @Pointcut ( '@within(org.openmmlab.platform.common.annotation.OnlyIntranetAccess)' )
 public void onlyIntranetAccessOnClass () {}
 @Pointcut ( '@annotation(org.openmmlab.platform.common.annotation.OnlyIntranetAccess)' )
 public void onlyIntranetAccessOnMethed () {
 }

 @Before ( value = 'onlyIntranetAccessOnMethed() || onlyIntranetAccessOnClass()' )
 public void before () {
     HttpServletRequest hsr = (( ServletRequestAttributes ) RequestContextHolder.getRequestAttributes()) .getRequest ();
     String from = hsr.getHeader ( 'from' );
     if ( !StringUtils.isEmpty( from ) && 'public'.equals ( from )) {
        log.error ( 'This api is only allowed invoked by intranet source' );
        throw new MMException ( ReturnEnum.C_NETWORK_INTERNET_ACCESS_NOT_ALLOWED_ERROR);
            }
     }
 }

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OnlyIntranetAccess {
}

最后,在只能内网访问的接口上加上@OnlyIntranetAccess注解即可


@GetMapping ( '/role/add' )
@OnlyIntranetAccess
public String onlyIntranetAccess() {
    return '该接口只允许内部服务调用';
}



4. 网关路径匹配

在DailyMart项目中我采用的是第四种:即在网关中进行路径匹配。




该方案中我们将内网访问的接口全部以前缀/pv开头,然后在网关过滤器中根据路径找到具体校验器,如果是/pv访问的路径则直接提示禁止外部访问。







使用网关路径匹配方案不仅可以应对内网接口的问题,还可以扩展到其他校验机制上。
譬如,有的接口需要通过access_token进行校验,有的接口需要校验api_key 和 api_secret,为了应对这种不同的校验场景,只需要再实现一个校验类即可,由不同的子类实现不同的校验逻辑,扩展非常方便。

图片


最后说一句(求关注!别白嫖!)


如果这篇文章对您有所帮助,或者有所启发的话,求一键三连:点赞、转发、在看。


关注公众号:woniuxgg,在公众号中回复:笔记  就可以获得蜗牛为你精心准备的java实战语雀笔记,回复面试、开发手册、有超赞的粉丝福利!


作者:程序员蜗牛
来源:juejin.cn/post/7389092138900717579
收起阅读 »

Tauri2.0 发布!不止于桌面!这次的“王炸”是移动端支持

开发桌面应用已经不再是唯一的战场,随着移动设备的普及,跨平台开发成了趋势。最近,Tauri带来了一个让开发者眼前一亮的功能——移动端支持。是的,Tauri不仅能开发轻量级桌面应用,还可以打通移动平台。这就像是它的“王炸”,在保持轻量化和高效的同时,直接扩展到移...
继续阅读 »

开发桌面应用已经不再是唯一的战场,随着移动设备的普及,跨平台开发成了趋势。最近,Tauri带来了一个让开发者眼前一亮的功能——移动端支持。是的,Tauri不仅能开发轻量级桌面应用,还可以打通移动平台。这就像是它的“王炸”,在保持轻量化和高效的同时,直接扩展到移动端,瞬间吊打许多传统框架。



接下来,我们一起来看看Tauri的移动端支持,如何让它成为开发者的新宠。


1. 从桌面到移动:跨平台的真正意义


首先,我们要明确一点,Tauri的核心竞争力就是跨平台开发。过去它的焦点主要集中在Windows、macOS和Linux三大桌面系统上,能够让你用前端技术快速开发出轻量级桌面应用。如今,它打破了这一界限,开始支持iOS和Android移动端,这让开发者可以在一个统一的框架下,写出同时适配桌面和移动的应用。



对于开发者来说,这意味着更高的开发效率——你不需要为不同的系统做繁琐的适配工作,也不必纠结于不同平台的特性差异。只需要一个代码库,Tauri就能帮你搞定桌面和移动端,真正实现“一次开发,多端运行”。


2. 移动端支持的背后:依然轻量化


说到跨平台框架,大家可能会想到Flutter或者React Native,它们也支持移动端开发,但往往伴随着较大的应用体积和较高的资源消耗。Tauri则依然保持了它一贯的轻量化特性。通过依赖系统自带的WebView,Tauri的应用体积相对其他框架要小得多。



举个例子,同样是一个展示信息的应用,使用Tauri开发的移动端应用安装包可能比Flutter小很多。对用户来说,这种轻量化带来的优势显而易见:更快的下载速度、更少的存储空间占用,尤其适合那些存储空间紧张的设备。


3. 全平台一致的开发体验


Tauri的另一大优势就是它对开发者友好的体验。如果你是前端开发者,你可以继续使用你熟悉的前端技术栈(例如React、Vue、Svelte等),几乎没有学习成本。现在,你不仅能用这些技术来开发桌面应用,还可以直接迁移到移动端,这样的开发一致性大大降低了学习和维护成本。


而且,Tauri的API不仅支持桌面系统的功能调用,现在也在逐步支持移动端的特性。这意味着你可以在同一个代码库中同时调用桌面和移动端的系统功能,而不必为不同的设备写不同的代码。


例如,你可以通过Tauri提供的API来访问手机的传感器、相机等功能,未来它还将进一步扩展对移动端特性的支持。


import { open } from '@tauri-apps/plugin-dialog';
// when using `"withGlobalTauri": true`, you may use
// const { open } = window.__TAURI__.dialog;

// Open a dialog
const file = await open({
  multiple: false,
  directory: false,
});
console.log(file);
// Prints file path or URI

上面的代码不仅可以在桌面应用中运行,未来也能扩展到移动端,实现类似的功能调用。这种开发体验的统一性,对于希望快速上线跨平台应用的开发者来说,绝对是个福音。


4. Tauri在移动端的性能表现


性能问题总是开发者最关心的点。和Flutter这类框架不同,Tauri并没有选择单独构建一个跨平台的UI框架,而是借助操作系统自带的WebView,这样不仅保证了轻量化,还能依托于WebView的优化,带来较好的性能表现。


在移动端,Tauri同样依赖于系统的WebView,这意味着你不必担心额外的资源开销。只要用户的系统WebView版本足够新,应用的启动速度和渲染性能都能得到保障。而且,Tauri团队还在不断优化移动端的支持,未来的性能提升值得期待。


5. 为什么选择Tauri开发移动端应用?


总结一下,如果你是一名前端开发者,或者希望在一个框架下同时支持桌面和移动端,Tauri无疑是一个非常有吸引力的选择。它不仅帮助你用熟悉的前端技术快速开发应用,还通过轻量化设计和跨平台支持,解决了传统框架的种种痛点。


无论是应用体积、性能还是开发体验,Tauri都提供了一个高效且轻量的解决方案。对于那些追求高效开发、同时需要支持桌面和移动端的项目,Tauri的移动端支持就是它的“王炸”。


一些思考:



随着Tauri逐步扩展对移动端的支持,它正从一个桌面应用开发框架,进化为一个真正的全平台开发工具。如果你正在寻找一个轻量化的跨平台解决方案,或者想让你的应用跑在更多设备上,不妨试试Tauri,说不定这就是你一直在找的那个“它”。但是,如果说你的团队准备做一个现象级的产品,可能目前还并不适合,因为 tarui 团队自身看起来也并没有敲定移动端的完美方案,可能后续还会有所调整,追求性能、安全性的同时,还有一个更重要的事情是,兼容性,稳定性,你觉得呢?欢迎留言,掰扯掰扯。


作者:brzhang
来源:juejin.cn/post/7420980084361625600
收起阅读 »

实现基于uni-app的项目自动检查APP更新

我们平时工作中开发APP时,及时为用户提供应用更新是提升用户体验和保证功能完整性的重要一环。本文将通过一段实际的代码示例,详细介绍如何在基于uni-app框架的项目中实现自动检查应用更新的功能,并引导用户完成升级过程。该功能主要涉及与服务器端交互获取最新版本信...
继续阅读 »

我们平时工作中开发APP时,及时为用户提供应用更新是提升用户体验和保证功能完整性的重要一环。本文将通过一段实际的代码示例,详细介绍如何在基于uni-app框架的项目中实现自动检查应用更新的功能,并引导用户完成升级过程。该功能主要涉及与服务器端交互获取最新版本信息、比较版本号、提示用户升级以及处理下载安装流程。



创建一个checkappupdate.js文件


这个文件是写升级逻辑处理的文件,可以不创建,直接在App.vue中写,但是为了便于维护,还是单独放出来比较好,可以放在common或者util目录中(App.vue能引入到就行,随意放,根目录也行),App.vue中引入该文件,调用升级函数如下图所示:


image.png


js完整代码


为了防止一点点代码写,容易让人云里雾里,先放完整代码,稍后再详细解释,其实看注释也就够了。


//这是服务端请求url配置文件,如果你直接卸载下面的请求中,可以不引入
import configService from '@/common/service/config.service.js'

export default function checkappupdate(param = {}) {
// 合并默认参数
param = Object.assign({
title: "A new version has been detected!",
content: "Please upgrade the app to the latest version!",
canceltext: "No upgrade",
oktext: "Upgrade now"
}, param)

plus.runtime.getProperty(plus.runtime.appid, (widgetInfo) => {
let platform = plus.os.name.toLocaleLowerCase() //Android
let os_version = plus.os.version //13 安卓版本
let vendor = plus.device.vendor //Xiaomi
let url = configService.apiUrl
uni.request({
url: url + '/checkAppUpdate',
method: 'GET',
data: {
platform: platform,
os_version: os_version,
vendor: vendor,
cur_version: widgetInfo.version
},
success(result) {
console.log(result)
let versionCode = parseInt(widgetInfo.versionCode)
let data = result.data ? result.data : null;
// console.log(data);
let downAppUrl = data.url
//判断版本是否需要升级
if (versionCode >= data.versionCode) {
return;
}
//升级提示
uni.showModal({
title: param.title,
content: data.log ? data.log : param.content,
showCancel: data.force ? false : true,
confirmText: param.oktext,
cancelText: param.canceltext,
success: res => {
if (!res.confirm) {
console.log('Cancel the upgrade');
// plus.runtime.quit();
return
}
// if (data.shichang === 1) {
// //去应用市场更新
// plus.runtime.openURL(data.shichangurl);
// plus.runtime.restart();
// } else {
// 开始下载
// 创建下载任务
var dtask = plus.downloader.createDownload(downAppUrl, {
filename: "_downloads/"
},
function (d, status) {
// 下载完成
if (status == 200) {
plus.runtime.install(d.filename, {
force: true
}, function () {
//进行重新启动;
plus.runtime.restart();
}, (e) => {
uni.showToast({
title: 'install fail:' + JSON
.stringify(e),
icon: 'none'
})
console.log(JSON.stringify(e))
});
} else {
this.tui.toast("download fail,error code: " +
status);
}
});
let view = new plus.nativeObj.View("maskView", {
backgroundColor: "rgba(0,0,0,.6)",
left: ((plus.screen.resolutionWidth / 2) - 45) +
"px",
bottom: "80px",
width: "90px",
height: "30px"
})

view.drawText('start download...', {}, {
size: '12px',
color: '#FFFFFF'
});
view.show()
// console.log(dtask);
dtask.addEventListener("statechanged", (e) => {
if (e && e.downloadedSize > 0) {
let jindu = ((e.downloadedSize / e.totalSize) *
100).toFixed(2)
view.reset();
view.drawText('Progress:' + jindu + '%', {}, {
size: '12px',
color: '#FFFFFF'
});
}
}, false);
dtask.start();
// }
},
fail(e) {
console.log(e);
uni.showToast({
title: 'Request error'
})
}
})
}
})

});
}


函数定义:checkappupdate


定义核心函数checkappupdate,它接受一个可选参数param,用于自定义提示框的文案等信息。函数内部首先通过Object.assign合并默认参数与传入参数,以确保即使未传入特定参数时也能有良好的用户体验。


获取应用信息与环境变量


利用plus.runtime.getProperty获取当前应用的详细信息,包括但不限于应用ID、版本号(version)和版本号代码(versionCode),以及设备的操作系统名称、版本和厂商信息。这些数据对于后续向服务器请求更新信息至关重要。


请求服务器检查更新


构建包含平台信息、操作系统版本、设备厂商和当前应用版本号的请求参数,发送GET请求至配置好的API地址/checkAppUpdate,查询是否有新版本可用。后端返回参数参考下面:


   /**
* 检测APP升级
*/

public function checkAppUpdate()
{
$data['versionCode'] = 101;//更新的版本号
$data['url'] = 'http://xxx/app/xxx.apk';//下载地址
$data['force'] = true;//是否强制更新
return json_encode($data);//返回json格式数据到前端
}

比较版本与用户提示


一旦收到服务器响应,解析数据并比较当前应用的版本号与服务器提供的最新版本号。如果存在新版本,使用uni.showModal弹窗提示用户,展示新版本日志(如果有)及升级选项。此步骤充分考虑了是否强制更新的需求,允许开发者灵活配置确认与取消按钮的文案。


下载与安装新版本


用户同意升级后,代码将执行下载逻辑。通过plus.downloader.createDownload创建下载任务,并监听下载进度,实时更新进度提示。下载完成后,利用plus.runtime.install安装新APK文件,并在安装成功后调用plus.runtime.restart重启应用,确保新版本生效。


用户界面反馈


在下载过程中,通过创建原生覆盖层plus.nativeObj.View展示一个半透明遮罩和下载进度信息,给予用户直观的视觉反馈,增强了交互体验,进度展示稍微有点丑,可以提自己改改哈。


image.png


总结



通过上述步骤,我们实现了一个完整的应用自动检查更新流程,不仅能够有效通知用户新版本的存在,还提供了平滑的升级体验。此功能的实现,不仅提升了用户体验,也为产品迭代和功能优化提供了有力支持。开发者可以根据具体需求调整提示文案、下载逻辑、进度样式等细节,以更好地适配自身应用的特点和用户群体。



作者:掘金归海一刀
来源:juejin.cn/post/7367555191337828361
收起阅读 »

横扫鸿蒙弹窗乱象,SmartDialog出世

前言 但凡用过鸿蒙原生弹窗的小伙伴,就能体会到它们是有多么的难用和奇葩,什么AlertDialog,CustomDialog,SubWindow,bindXxx,只要大家用心去体验,就能发现他们有很多离谱的设计和限制,时常就是一边用,一边骂骂咧咧的吐槽 实属无...
继续阅读 »

前言


但凡用过鸿蒙原生弹窗的小伙伴,就能体会到它们是有多么的难用和奇葩,什么AlertDialog,CustomDialog,SubWindow,bindXxx,只要大家用心去体验,就能发现他们有很多离谱的设计和限制,时常就是一边用,一边骂骂咧咧的吐槽


实属无奈,就把鸿蒙版的SmartDialog写出来了


flutter自带的dialog是可以应对日常场景,例如:简单的打开一个弹窗,非UI模块使用,跨页面交互之类;flutter_smart_dialog 是补齐了大多数的业务场景和一些强大的特殊能力,flutter_smart_dialog 对于flutter而言,日常场景是锦上添花,特殊场景是雪中送炭


但是 ohos_smart_dialog 对于鸿蒙而言,日常场景就是雪中送炭!单单一个使用方式而言,就是吊打鸿蒙的CustomDialog,CustomDialog的各种限制和使用方式,我不想再去提及和吐槽了


有时候,简洁的使用,才是最大的魅力


鸿蒙版的SmartDialog有什么优势?



  • 单次初始化后即可使用,无需多处配置相关Component

  • 优雅,极简的用法

  • 非UI区域内使用,自定义Component

  • 返回事件处理,优化的跨页面交互

  • 多弹窗能力,多位置弹窗:上下左右中间

  • 定位弹窗:自动定位目标Component

  • 极简用法的loading弹窗

  • 等等......


目前 flutter_smart_dialog 的代码量16w+,完整复刻其功能,工作量非常大,目前只能逐步实现一些基础能力,由于鸿蒙api的设计和相关限制,用法和相关初始化都有一定程度的妥协


鸿蒙版本的SmartDialog,功能会逐步和 flutter_smart_dialog 对齐(长期),api会尽量保持一致


效果



  • Tablet 模拟器目前有些问题,会导致动画闪烁,请忽略;注:真机动画丝滑流畅,无任何问题


attachLocation


customTag


customJumpPage


极简用法


// dialog
SmartDialog.show({
builder: dialogArgs,
builderArgs: Math.random(),
})

@Builder
function dialogArgs(args: number) {
Text(args.toString()).padding(50).backgroundColor(Color.White)
}

// loading
SmartDialog.showLoading()

安装



ohpm install ohos_smart_dialog 

配置


下述的配置项,可能会有一点多,但,这也是为了极致的体验;同时也是无奈之举,相关配置难以在内部去闭环处理,只能在外部去配置


这些配置,只需要配置一次,后续无需关心


完成下述的配置后,你将可以在任何地方使用弹窗,没有任何限制


初始化



  • 因为弹窗需要处理跨页面交互,必须要监控路由


@Entry
@Component
struct Index {
navPathStack: NavPathStack = new NavPathStack()

build() {
Stack() {
// here: monitor router
Navigation(OhosSmartDialog.registerRouter(this.navPathStack)) {
MainPage()
}
.mode(NavigationMode.Stack)
.hideTitleBar(true)
.navDestination(pageMap)

// here
OhosSmartDialog()
}.height('100%').width('100%')
}
}

返回事件监听



别问我为啥返回事件的监听,处理的这么不优雅,鸿蒙里面没找全局返回事件监听,我也没辙。。。




  • 如果你无需处理返回事件,可以使用下述写法


// Entry页面处理
@Entry
@Component
struct Index {
onBackPress(): boolean | void {
return OhosSmartDialog.onBackPressed()()
}
}

// 路由子页面
struct JumpPage {
build() {
NavDestination() {
// ....
}
.onBackPressed(OhosSmartDialog.onBackPressed())
}
}


  • 如果你需要处理返回事件,在OhosSmartDialog.onBackPressed()中传入你的方法即可


// Entry页面处理
@Entry
@Component
struct Index {
onBackPress(): boolean | void {
return OhosSmartDialog.onBackPressed(this.onCustomBackPress)()
}

onCustomBackPress(): boolean {
return false
}
}

// 路由子页面
@Component
struct JumpPage {
build() {
NavDestination() {
// ...
}
.onBackPressed(OhosSmartDialog.onBackPressed(this.onCustomBackPress))
}

onCustomBackPress(): boolean {
return false
}
}

路由监听



  • 一般来说,你无需关注SmartDialog的路由监听,因为内部已经设置了路由监听拦截器

  • 但是,NavPathStack仅支持单拦截器(setInterception),如果业务代码也使用了这个api,会导致SmartDialog的路由监听被覆盖,从而失效



如果出现该情况,请参照下述解决方案




  • 在你的路由监听类中手动调用OhosSmartDialog.observe


export default class YourNavigatorObserver implements NavigationInterception {
willShow?: InterceptionShowCallback = (from, to, operation, isAnimated) => {
OhosSmartDialog.observe.willShow?.(from, to, operation, isAnimated)
// ...
}
didShow?: InterceptionShowCallback = (from, to, operation, isAnimated) => {
OhosSmartDialog.observe.didShow?.(from, to, operation, isAnimated)
// ...
}
}

适配暗黑模式



  • 为了极致的体验,深色模式切换时,打开态弹窗也应刷新为对应模式的样式,故需要进行下述配置


export default class EntryAbility extends UIAbility {  
onConfigurationUpdate(newConfig: Configuration): void {
OhosSmartDialog.onConfigurationUpdate(newConfig)
}
}

SmartConfig



  • 支持全局配置弹窗的默认属性


function init() {
// show
SmartDialog.config.custom.maskColor = "#75000000"
SmartDialog.config.custom.alignment = Alignment.Center

// showAttach
SmartDialog.config.attach.attachAlignmentType = SmartAttachAlignmentType.center
}


  • 检查弹窗是否存在


// 检查当前是否有CustomDialog,AttachDialog或LoadingDialog处于打开状态
let isExist = SmartDialog.checkExist()

// 检查当前是否有AttachDialog处于打开状态
let isExist = SmartDialog.checkExist({ dialogTypes: [SmartAllDialogType.attach] })

// 检查当前是否有tag为“xxx”的dialog处于打开状态
let isExist = SmartDialog.checkExist({ tag: "xxx" })

配置全局默认样式



  • ShowLoading 自定样式十分简单


SmartDialog.showLoading({ builder: customLoading })

但是对于大家来说,肯定是想用 SmartDialog.showLoading() 这种简单写法,所以支持自定义全局默认样式



  • 需要在 OhosSmartDialog 上配置自定义的全局默认样式


@Entry
@Component
struct Index {
build() {
Stack() {
OhosSmartDialog({
// custom global loading
loadingBuilder: customLoading,
})
}.height('100%').width('100%')
}
}

@Builder
export function customLoading(args: ESObject) {
LoadingProgress().width(80).height(80).color(Color.White)
}


  • 配置完你的自定样式后,使用下述代码,就会显示你的 loading 样式


SmartDialog.showLoading()

// 支持入参,可以在特殊场景下灵活配置
SSmartDialog.showLoading({ builderArgs: 1 })

CustomDialog



  • 下方会共用的方法


export function randomColor(): string {
const letters: string = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}

export function delay(ms?: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

传参弹窗


export function customUseArgs() {
SmartDialog.show({
builder: dialogArgs,
// 支持任何类型
builderArgs: Math.random(),
})
}

@Builder
function dialogArgs(args: number) {
Text(`${args}`).fontColor(Color.White).padding(50)
.borderRadius(12).backgroundColor(randomColor())
}

customUseArgs


多位置弹窗


export async function customLocation() {
const animationTime = 1000
SmartDialog.show({
builder: dialogLocationHorizontal,
alignment: Alignment.Start,
})
await delay(animationTime)
SmartDialog.show({
builder: dialogLocationVertical,
alignment: Alignment.Top,
})
}


@Builder
function dialogLocationVertical() {
Text("location")
.width("100%")
.height("20%")
.fontSize(20)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.padding(50)
.backgroundColor(randomColor())
}

@Builder
function dialogLocationHorizontal() {
Text("location")
.width("30%")
.height("100%")
.fontSize(20)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.padding(50)
.backgroundColor(randomColor())
}

customLocation


跨页面交互



  • 正常使用,无需设置什么参数


export function customJumpPage() {
SmartDialog.show({
builder: dialogJumpPage,
})
}

@Builder
function dialogJumpPage() {
Text("JumPage")
.fontSize(30)
.padding(50)
.borderRadius(12)
.fontColor(Color.White)
.backgroundColor(randomColor())
.onClick(() => {
// 跳转页面
})
}

customJumpPage


关闭指定弹窗


export async function customTag() {
const animationTime = 1000
SmartDialog.show({
builder: dialogTagA,
alignment: Alignment.Start,
tag: "A",
})
await delay(animationTime)
SmartDialog.show({
builder: dialogTagB,
alignment: Alignment.Top,
tag: "B",
})
}

@Builder
function dialogTagA() {
Text("A")
.width("20%")
.height("100%")
.fontSize(20)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.padding(50)
.backgroundColor(randomColor())
}

@Builder
function dialogTagB() {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(["closA", "closeSelf"], (item: string, index: number) => {
Button(item)
.backgroundColor("#4169E1")
.margin(10)
.onClick(() => {
if (index === 0) {
SmartDialog.dismiss({ tag: "A" })
} else if (index === 1) {
SmartDialog.dismiss({ tag: "B" })
}
})
})
}.backgroundColor(Color.White).width(350).margin({ left: 30, right: 30 }).padding(10).borderRadius(10)
}

customTag


自定义遮罩


export function customMask() {
SmartDialog.show({
builder: dialogShowDialog,
maskBuilder: dialogCustomMask,
})
}

@Builder
function dialogCustomMask() {
Stack().width("100%").height("100%").backgroundColor(randomColor()).opacity(0.6)
}

@Builder
function dialogShowDialog() {
Text("showDialog")
.fontSize(30)
.padding(50)
.fontColor(Color.White)
.borderRadius(12)
.backgroundColor(randomColor())
.onClick(() => customMask())
}

customMask


AttachDialog


默认定位


export function attachEasy() {
SmartDialog.show({
builder: dialog
})
}

@Builder
function dialog() {
Stack() {
Text("Attach")
.backgroundColor(randomColor())
.padding(20)
.fontColor(Color.White)
.borderRadius(5)
.onClick(() => {
SmartDialog.showAttach({
targetId: "Attach",
builder: targetLocationDialog,
})
})
.id("Attach")
}
.borderRadius(12)
.padding(50)
.backgroundColor(Color.White)
}

@Builder
function targetLocationDialog() {
Text("targetIdDialog")
.fontSize(20)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.padding(50)
.borderRadius(12)
.backgroundColor(randomColor())
}

attachEasy


多方向定位


export function attachLocation() {
SmartDialog.show({
builder: dialog
})
}

class AttachLocation {
title: string = ""
alignment?: Alignment
}

const locationList: Array<AttachLocation> = [
{ title: "TopStart", alignment: Alignment.TopStart },
{ title: "Top", alignment: Alignment.Top },
{ title: "TopEnd", alignment: Alignment.TopEnd },
{ title: "Start", alignment: Alignment.Start },
{ title: "Center", alignment: Alignment.Center },
{ title: "End", alignment: Alignment.End },
{ title: "BottomStart", alignment: Alignment.BottomStart },
{ title: "Bottom", alignment: Alignment.Bottom },
{ title: "BottomEnd", alignment: Alignment.BottomEnd },
]

@Builder
function dialog() {
Column() {
Grid() {
ForEach(locationList, (item: AttachLocation) => {
GridItem() {
buildButton(item.title, () => {
SmartDialog.showAttach({
targetId: item.title,
alignment: item.alignment,
maskColor: Color.Transparent,
builder: targetLocationDialog
})
})
}
})
}.columnsTemplate('1fr 1fr 1fr').height(220)

buildButton("allOpen", async () => {
for (let index = 0; index < locationList.length; index++) {
let item = locationList[index]
SmartDialog.showAttach({
targetId: item.title,
alignment: item.alignment,
maskColor: Color.Transparent,
builder: targetLocationDialog,
})
await delay(300)
}
}, randomColor())
}
.borderRadius(12)
.width(700)
.padding(30)
.backgroundColor(Color.White)
}

@Builder
function buildButton(title: string, onClick?: VoidCallback, bgColor?: ResourceColor) {
Text(title)
.backgroundColor(bgColor ?? "#4169E1")
.constraintSize({ minWidth: 120, minHeight: 46 })
.margin(10)
.textAlign(TextAlign.Center)
.fontColor(Color.White)
.borderRadius(5)
.onClick(onClick)
.id(title)
}

@Builder
function targetLocationDialog() {
Text("targetIdDialog")
.fontSize(20)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.padding(50)
.borderRadius(12)
.backgroundColor(randomColor())
}

attachLocation


Loading


对于Loading而言,应该有几个比较明显的特性



  • loading和dialog都存在页面上,哪怕dialog打开,loading都应该显示dialog之上

  • loading应该具有单一特性,多次打开loading,页面也应该只存在一个loading

  • 刷新特性,多次打开loading,后续打开的loading样式,应该覆盖之前打开的loading样式

  • loading使用频率非常高,应该支持强大的拓展和极简的使用


从上面列举几个特性而言,loading是一个非常特殊的dialog,所以需要针对其特性,进行定制化的实现


当然了,内部已经屏蔽了细节,在使用上,和dialog的使用没什么区别


默认loading


SmartDialog.showLoading()

loadingDefault


自定义Loading



  • 点击loading后,会再次打开一个loading,从效果图可以看出它的单一刷新特性


export function loadingCustom() {
SmartDialog.showLoading({
builder: customLoading,
})
}

@Builder
export function customLoading() {
Column({ space: 5 }) {
Text("again open loading").fontSize(16).fontColor(Color.White)
LoadingProgress().width(80).height(80).color(Color.White)
}
.padding(20)
.borderRadius(12)
.onClick(() => loadingCustom())
.backgroundColor(randomColor())
}

loadingCustom


最后


鸿蒙版的SmartDialog,相信会对开发鸿蒙的小伙伴们有一些帮助~.~


现在就业环境真是让人头皮发麻,现在的各种技术群里,看到好多人公司各种拖欠工资,各种失业半年的情况


淦,不知道还能写多长时间代码!


004B5DB3


作者:小呆呆666
来源:juejin.cn/post/7401056900878368807
收起阅读 »

Flutter 七年之约:2024 年将带来哪些惊喜?

Flutter 作为目前最出色的跨平台开发框架之一,自2017年Google向公众发布它以来,已经走过了七年的历程。在这段时间里,成千上万的开发者和团队选择了使用 Flutter 来开发他们的应用程序。时至今日,Google 仍然坚定地支持着Flutter的发...
继续阅读 »

flutter-2024-roadmap 2.webp



Flutter 作为目前最出色的跨平台开发框架之一,自2017年Google向公众发布它以来,已经走过了七年的历程。在这段时间里,成千上万的开发者和团队选择了使用 Flutter 来开发他们的应用程序。时至今日,Google 仍然坚定地支持着Flutter的发展。接下来就让我们通过Flutter的2024年的路线图,一起来了解 Flutter 的未来规划吧。




  • 作为一个开源项目,Flutter 团队希望通过公开计划来增加透明度,并邀请社区成员共同参与项目的开发。这份路线图涵盖了核心框架和引擎的改进、对移动和桌面平台的支持,以及生态系统的扩展

  • 这份路线图充满了前瞻性,展示了 Flutter 和 Dart 社区中最活跃的贡献者今年计划进行的工作。通常情况下,很难对所有的工程工作做出明确的承诺,尤其是对于一个拥有数百名贡献者的开源项目来说。


核心框架和引擎


Flutter 将继续专注于通过 Impeller 提升质量和性能。Flutter 的目标是完成 iOS 向 Impeller 的迁移,这包括移除 iOS 上的 Skia 后端。在 Android 上,Flutter 预计 Impeller 将支持 Vulkan 和 OpenGLES。不过短期内,用户也可以选择继续使用 Skia。此外,Flutter 还计划改进 Impeller 的测试基础设施,以减少生产中的回归问题。


对于核心框架,Flutter 计划全面支持 Material 3。Flutter 也在研究如何让核心框架更通用化,以更好地支持苹果设备的设计需求,比如应用栏和选项卡栏的优化。


Flutter 还将继续推进 blankcanvas项目的工作。


移动平台(Android 和 iOS)


在 2023 年,Flutter 启动了支持多个 Flutter 视图的计划。到 2024 年,Flutter计划将这一支持扩展到 Android 和 iOS 平台。Flutter也在努力提高平台视图的性能,以及测试覆盖率和可测试性。


Flutter将继续通过支持最新的苹果标准(如 隐私清单 和 Swift 包管理器)来提升 iOS 产品的现代化水平。同时,Flutter也在评估对未来 Android 版本的支持需求。


在 Android 平台上,Flutter会研究如何在构建文件中支持 Kotlin。


互操作性对 Dart 与本地代码的交互非常重要。Flutter计划完成从 Dart 直接调用 Objective C 代码的支持工作,并探索如何支持直接调用 Swift 代码。同样,Flutter将继续改进 调用 Java 和 Android 的功能。Flutter还在研究如何更好地支持只能在主操作系统或平台线程上调用的 API。


越来越多的大型 Flutter 应用开始作为混合应用(包含 Flutter 代码和部分 Android/iOS 平台代码或 UI)开发。Flutter将研究如何在性能、资源开销和开发体验方面更好地支持这种模式。


Web 平台


Flutter 将继续专注于提升 Web 平台的性能和质量。这包括研究如何减少应用程序的整体大小、更好地利用多线程、支持平台视图、改善应用加载时间、将 CanvasKit 设为默认渲染器、改进文本输入,并探索支持 Flutter web 的 SEO 的选项


Flutter计划完成将 Dart 编译为 WasmGC 的工作,以支持 Wasm 编译 Flutter web 应用。这还包括一个新的 Dart 的 JS 互操作 机制,支持 JS 和 Wasm 编译。


Flutter还计划恢复对 web 上的热重载 的支持。


桌面平台


虽然 Flutter 的主要精力将继续放在移动和 Web 平台上,但Flutter也计划在桌面平台上进行一些改进:



  • Flutter希望在 macOS 和 Windows 上支持平台视图,以便支持例如 webview 的功能。

  • 在 Linux 上,Flutter的重点是支持 GTK4 和提升无障碍性。

  • 在所有平台上,Flutter将继续探索如何从一个 Dart isolate 支持多个视图,最终实现从一个小部件树渲染多个窗口。


生态系统


Flutter 计划与 AI 框架合作,推动 AI 驱动的 Flutter 应用的新时代。


Flutter不会扩大Flutter维护的 flutter.dev 插件 集合,而是专注于提升现有插件的质量,并解决核心功能差距(例如,改进 shared_preferences API,使其更好地支持隔离使用和集成到应用场景中)。Flutter还将支持社区倡议,如 Flutter Favorites。


Flutter还将继续与 Flame 社区合作,支持使用 Flutter 构建休闲游戏。


工具和 AI


Flutter 希望通过集成 AI 解决方案,为开发者提供编程任务的 AI 支持。


Flutter也将继续与谷歌的 IDX 团队 合作,探索与设计工具的集成。


编程语言


Dart 团队计划完成对在 Dart 中支持 宏 的可行性评估,并在 2024 年启动对宏的初步支持。如果发现不可克服的架构问题,Flutter将重新评估这一努力。宏的关键应用场景包括序列化/反序列化、数据类和通用扩展性。


Flutter将研究多个渐进的语言特性,例如减少冗长的语法(如 主构造函数 和 导入语法简写),以及更好地支持静态检查的类型差异。


最后,Flutter将探索如何在更多地方重用 Dart 业务逻辑,并为 Dart 提供更多的插件化和扩展性(例如在 DevTools 和分析器中)。


发布


Flutter 计划在 2024 年进行四次稳定版发布和 12 次测试版发布,这与 2023 年的节奏相似。


最后


最后,大家最关心的Flutter热更新合适支持? 在2024 Flutter团队依然没有支持热更新的计划。



作者:CrazyCodeBoy
来源:juejin.cn/post/7408848095615713295
收起阅读 »

老板说,2 天开发一个 App,双端支持,我做到了

老板说,2 天开发一个 App,我用 Expo 做到了,当然,学习怎么使用 Expo 花了1个小时时间不算哈。Expo 是一个非常强大的工具,特别适合那些想要快速构建和发布React Native应用的开发者。你有没有遇到过这种情况?刚刚上手React Nat...
继续阅读 »


老板说,2 天开发一个 App,我用 Expo 做到了,当然,学习怎么使用 Expo 花了1个小时时间不算哈。Expo 是一个非常强大的工具,特别适合那些想要快速构建和发布React Native应用的开发者。你有没有遇到过这种情况?刚刚上手React Native,发现配置开发环境、调试代码这些事情耗费了太多时间,而你真正想做的是快速看到成果。那么,Expo 就是为你量身定做的解决方案。


首先,Expo 是一个开源框架,背后有一个强大的社区支持。你可以在 Expo 的 GitHub 仓库 找到它的源码、更新日志以及社区贡献的内容。这也意味着,你可以完全掌控你项目的每一个细节,而且社区成员之间的经验分享和合作让开发变得更加顺畅。


1. Expo 的核心特点


你可能会问,Expo 和普通的 React Native 开发有什么不同?Expo 的一大特点就是“省心”。它帮你封装了大量底层配置,让你不需要花时间在复杂的环境搭建上。想要启动一个新项目?只需几条命令,你的开发环境就配置好了,甚至不需要接触到原生代码。这对于不太熟悉 iOS 和 Android 原生开发的前端开发者来说,简直是福音。话又说回来,如果想看源码,人家也没拦着你,因为生态是开源的。


2. 零门槛开发


如果你还没用过 Expo CLI,那你一定要试试。通过几条简单的命令,你就可以创建并运行一个 React Native 应用。Expo Go 应用甚至允许你直接在手机上预览你的应用,而不需要复杂的配置。这就像是给你装了一双翅膀,让你可以随时随地测试你的应用。


🗄️ npx create-expo-app@latest
🌭 bunx create-expo-app
📦 pnpm create expo-app
🧶 yarn create expo-app

3. 丰富的生态系统


Expo 的生态系统也是它的一大亮点。它内置了大量的常用功能模块,比如相机、位置服务、传感器等等,你可以直接调用这些API,而不需要自己动手去编写原生代码。而且,Expo SDK 每年都会发布几次更新,哦不好意思,每个月都会更新,奶奶的,我刚用就从 50 更新到 51 了,也够速度的,但是好在,是兼容的,好处是确保你能用上最新最酷的功能,比如 react native 的恐怖性能的新架构,妥妥的给安排上。



你也不用担心这些功能的性能问题。Expo 团队非常注重性能优化,确保你的应用能在各类设备上流畅运行。


使用相机,使用数据库啥的,一个 import 搞定,兼容 API,双端几乎一致的体验简直爽大爆炸。


import { CameraView } from 'expo-camera';
import * as SQLite from 'expo-sqlite';

4. 云端构建与发布


说到发布,Expo 还提供了EAS(Expo Application Services),这个服务可以帮你处理繁琐的构建和发布流程。你只需专注于开发,剩下的事情交给EAS就好。无论是 iOS 还是 Android 平台,它都能帮你轻松搞定。更棒的是,你可以通过EAS进行云端构建,不再需要配置繁琐的构建环境。我比较好奇的是他尽然帮我托管了我的签名,所以基本上意味着交给 eas 去构建,发布到 Google play,和 App Store 就是点点鼠标的事情,但是前提是你得功能测试过,不要闪退和白屏。


5. 社区与支持


最让人欣慰的是,Expo 背后有一个活跃的社区。你可以随时在GitHub上提出问题,或者浏览别人已经解决的类似问题。除此之外,Expo 的文档非常详细,新手也能很快上手。如果你想了解某个API的用法,文档里都有详细的示例代码,这让学习曲线变得非常平滑。我遇到的一些问题就是在 docs 上找答案,比如如何本地构建,如何弹出原生模块,因为有可能需要做一些原生开发。


6. 什么时候不该用Expo?


当然,Expo 也并不是万能的。如果你需要使用某些非常特殊的原生功能,Expo 可能并不能完全满足你的需求。在这种情况下,你可能需要“弹出”Expo(也就是所谓的“eject”),从而使用纯粹的 React Native 环境。这时候,你就要自己管理所有原生模块了。


不过,对于大多数应用开发者来说,特别是那些不太熟悉原生开发的前端,Expo 已经足够强大。这里页打一只强心针,只要不是那些小众的三方库,比如腾讯云 cos,基本上问题不大。


个人感觉,Expo是简化了开发流程的,而且哦还为你提供了强大的工具和服务。你只需要专注于编写业务代码,正在做移动端,或者想做移动端开发的,快去试试吧,我相信你会爱上它的。


反问一波


那位说,你知道不是搞 Flutter 的吗,怎么突然就用 react native 了呢?我想说的是,这些都是工具而已,就好比我们夹菜用筷子,喝粥用瓢羹。关键看什么需求,如果你的 App 要求双端 UI 渲染一致性非常高,有非常多高性能动画的需求,那么 Flutter 很适合你,如果你需要快速实现需求,对双端一致性没那么强,且你对 web 开发很熟悉,ok,react native 是你比较好的选择,能说的就是这么多。


作者:brzhang
来源:juejin.cn/post/7403288145197744168
收起阅读 »

Flutter局部刷新三剑客

局部刷新作为提高Flutter页面性能的重要手段,是每一个Flutter老手都必须掌握的技巧。当然,我们不用非得使用Riverpod、Provider、Bloc这些状态管理工具来实现局部刷新,Flutter框架本身也给我们提供了很多方便快捷的刷新方案,今天要提...
继续阅读 »

局部刷新作为提高Flutter页面性能的重要手段,是每一个Flutter老手都必须掌握的技巧。当然,我们不用非得使用Riverpod、Provider、Bloc这些状态管理工具来实现局部刷新,Flutter框架本身也给我们提供了很多方便快捷的刷新方案,今天要提的就是Notifier三剑客,用它来处理局部刷新,代码优雅又方便,可谓是居家必备之良器。


ChangeNotifier


ChangeNotifier作为数据提供方,给出了响应式编程的基础,我们先来看看ChangeNotifier的源码。
image.png
作为一个mixin,它就是实现了Listenable,这又是个什么呢?
image.png
这个抽象类,实际上就是实现了addListener和removeListener两个监听的处理。所以接下来我们看看ChangeNotifier是如何实现者两个方法的。
image.png
源码很简单,就是创建的listener添加到_listeners列表中。
image.png
移除也很简单。最后看下核心的notifyListeners方法。
image.png
这个方法就是遍历_listeners,来触发监听Callback。整体就是一个标准的「订阅-发布」流程。


作为Notifier家族的长辈,它的使用会略复杂一些,我们来看一个例子。首先,需要mixin一个ChangeNotifier。


class CountNotifier with ChangeNotifier {
int count = 0;

void increase() {
++count;
notifyListeners();
}
}

然后再创建一个TestWidget来调用这个ChangeNotifier。


class CountNotifierWidget extends StatefulWidget {
const CountNotifierWidget({super.key});

@override
State<StatefulWidget> createState() {
return _CountNotifierState();
}
}

class _CountNotifierState extends State<CountNotifierWidget> {
final CountNotifier _countNotify = CountNotifier();
int _count = 0;

@override
void initState() {
super.initState();
_countNotify.addListener(updateCount);
}

void updateCount() {
setState(() {
_count = _countNotify.count;
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text("Test: $_count"),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _countNotify.increase(),
child: const Icon(Icons.add),
),
);
}

@override
void dispose() {
super.dispose();
_countNotify.removeListener(updateCount);
}
}

这样当我们修改ChangeNotifier的value的时候,就会Callback到updateCount实现刷新。


这样就形成了一个响应式的基础模型,数据修改,监听者刷新UI,完成了响应式的同时,也实现了局部刷新的功能,提高了性能。


ValueNotifier


在使用ChangeNotifier的时候,每次在修改变量时,都需要手动调用notifyListeners()方法,所以,Flutter创建了一个新的组件——ValueNotifier,它的源码如下。
image.png
从源码可以看见,ValueNotifier就是在set方法中,帮你调用了下notifyListeners()方法。同时,ValueNotifier封装了一个泛型变量,简化了ChangeNotifier的创建过程,所以大部分时间我们都是直接使用ValueNotifier。


那么有了它之后,我们就可以省去新建类的步骤,对于单一的基础类型变量,直接创建ValueNotifier即可,就像上面的例子,我们可以直接改造成下面这样。


class _CountNotifierState extends State<CountNotifierWidget> {
final ValueNotifier<int> _countNotify = ValueNotifier(0);

@override
void initState() {
super.initState();
_countNotify.addListener(updateCount);
}

void updateCount() {
setState(() {});
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text("Test: ${_countNotify.value}"),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _countNotify.value++,
child: const Icon(Icons.add),
),
);
}

@override
void dispose() {
super.dispose();
_countNotify.removeListener(updateCount);
}
}

这样我们就简化了不少的模板代码。


ValueListenableBuilder


我们从ChangeNotifier到ValueNotifier,逐步减少了模板代码的创建,但是依然还有很多问题,比如我们还是需要手动addListener、removeListener或者是dispose,同时,还需要使用setState来刷新页面,如果Context控制不好,很容易造成整个页面的刷新。因此,Flutter在它们的基础之上,又提供了ValueListenableBuilder来解决上面这些问题。


我们继续改造上面的例子。


class _CountNotifierState extends State<CountNotifierWidget> {
final ValueNotifier<int> _countNotify = ValueNotifier(0);

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ValueListenableBuilder<int>(
valueListenable: _countNotify,
builder: (context, value, child) {
return Text('Value: $value');
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _countNotify.value++,
child: const Icon(Icons.add),
),
);
}

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

可以发现,我们使用ValueListenableBuilder来根据ValueNotifier的改变而刷新Widget。这样不仅简化了代码模板,而且不再使用setState来进行页面刷新。


ValueListenableBuilder作为一个非常经典的Widget,在它的注释中,就有很多教程和示例。
image.png
再看它的源码。
image.png
这里需要接收3个参数,其中valueListenable用来接收ValueNotifier,builder用来构建Widget,而child,用来创建不依赖ValueNotifier构建的Widget(这是一个很经典的性能优化的例子,如果子构建成本高,并且不依赖于通知符的值,我们将使用它进行优化)。



这个优化方案非常经典,在Flutter的很多地方都有使用这个技巧,特别是动画这块的处理。通常来说ValueNotifier对应ValueListenableBuilder,Listenable、ChangeNotifier对应AnimatedBuilder。




自定义类型


在使用自定义类型时,例如一个包装类,那么当你改变它的某个属性值时,ValueListenableBuilder是不会刷新的,我们来看下面这个例子。


class Wrapper {
int age;
String name;

Wrapper({this.age = 0, this.name = ''});
}

class _CountNotifierState extends State<CountNotifierWidget> {
final ValueNotifier<Wrapper> _countNotify = ValueNotifier(Wrapper());

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ValueListenableBuilder<Wrapper>(
valueListenable: _countNotify,
builder: (context, value, child) {
return Text('Value: ${value.age}');
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _countNotify.value.age = _countNotify.value.age + 1,
child: const Icon(Icons.add),
),
);
}

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

这样的话,ValueListenableBuilder就失去作用了,其原因也很简单,ValueNotifier所监听的数据其实并未发生改变,实例的内存地址没发生改变,所以,直接创建一个新的对象,就可以触发更新了,就像下面这样。


onPressed: () => _countNotify.value = Wrapper(age: 10),


自定义类型局部刷新


上面这种自定义模型的刷新方法还是略显复杂了一点,每次更新的时候,都要copy一下数据来实现更新,实际上,ValueNotifier继承自ChangeNotifier,所以可以通过手动调用notifyListeners的方式来进行刷新,我们改造下上面的例子。


class WrapperNotifier extends ValueNotifier<Wrapper> {
WrapperNotifier(Wrapper value) : super(value);

void increment() {
value.age++;
notifyListeners();
}
}

// 调用处
_countNotify.increment();

通过这种方式,我们可以实现当模型内部变量更新时,局部进行刷新了。


欢迎大家关注我的公众号——【群英传】,专注于「Android」「Flutter」「Kotlin」
我的语雀知识库——http://www.yuque.com/xuyisheng


作者:xuyisheng
来源:juejin.cn/post/7381767811679502346
收起阅读 »

为什么我建议Flutter中通过构造参数给页面传递信息

哈喽,我是老刘 前段时间有人问我这个问题碰到没有:Flutter - 升级3.19之后页面多次rebuild? 说实话我们没有碰到这个问题 我先来简单解释一下这个问题,本质上是因为使用了 InheritedWidget 通过InheritedWidget向子树...
继续阅读 »

哈喽,我是老刘


前段时间有人问我这个问题碰到没有:
Flutter - 升级3.19之后页面多次rebuild?

说实话我们没有碰到这个问题


我先来简单解释一下这个问题,本质上是因为使用了 InheritedWidget


通过InheritedWidget向子树传递数据


InheritedWidget可以向其子树中的所有Widget提供数据。这使得无关的Widget能方便地获取同一个InheritedWidget提供的数据,实现Widget树中不直接相关Widget之间的数据共享。

Flutter SDK 中正是通过 InheritedWidget 来共享应用主题(Theme)和 Locale(当前语言环境)信息的。

其使用方法如下

实现一个InheritedWidget


class MyInheritedWidget extends InheritedWidget {  // 继承InheritedWidget
final int data;
MyInheritedWidget({required this.data, required Widget child}) : super(child: child);
@override
// 这个方法定义了当数据发送变化时是否通知子树中的子Widget。
// 它返回一个布尔值,true表示通知子Widget,false表示不通知。
bool updateShouldNotify(MyInheritedWidget oldWidget) {
return oldWidget.data != data;
}
// 子Widget可以通过调用MyInheritedWidget.of()静态方法来获取MyInheritedWidget实例,并获取其提供的数据。
static MyInheritedWidget of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType()!;
}
}


获取InheritedWidget中的数据


class MyText extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text(
'${MyInheritedWidget.of(context).data}',
style: Theme.of(context).textTheme.headline4,
);
}
}


当InheritedWidget中的数据发生变化时,就会通知所有通过InheritedWidget.of()方法注册了关注数据变化的成员。

这时这些子树中的组件就会重绘。


其实前面文章中的问题就是当你使用ModalRoute.of(context)方法获取页面路由的参数时,其实也就是向一个全局级别的InheritedWidget节点注册了关注其变化。

而当这个全局的路由节点的行为发生变化后(页面退栈通知数据变化),就会出现原先没有的重绘现象出现了。


为什么我们的代码没有这个问题


我们在传递页面参数时其实是没有使用ModalRoute.of(context)的方式获取页面参数的。

我们使用的是页面类的构造参数。

举个例子,比如打开一个商品详情页,需要传递商品id作为页面参数。

代码如下


class ProductDetailPage extends StatefulWidget {
final String productId;

const ProductDetailPage({required this.productId});

@override
_ProductDetailPageState createState() => _ProductDetailPageState();
}

class _ProductDetailPageState extends State<ProductDetailPage> {
@override
void initState() {
super.initState();
// 使用productId获取商品详情
}

@override
Widget build(BuildContext context) {
// 根据productId构建UI
return Scaffold(
// ...
);
}
}


定义路由可以使用动态生成路由的方式:


MaterialApp(
onGenerateRoute: (RouteSettings settings) {
// 通过settings.name可以获取传入的路由名称,并据此返回不同的路由
final productId = settings.arguments['id'] as String;
return MaterialPageRoute(
builder: (context) => ProductDetailPage(productId: productId),
);
}
)


那为什么我们要选择这种方式传递页面参数,而不是ModalRoute.of(context)的方式呢?

这其实是一种本能,一种下意识的行为。


两个思维习惯


1、减少不可控因素


老刘写了十多年的代码了,光Flutter就写了快6年。

这么多年的实战形成的习惯就是对不可控的外部依赖心怀警惕。

InheritedWidget就是一种很典型的场景。

如果是自己写的InheritedWidget还好,但如果是外部的,比如系统SDK的。

那么你怎么保证它通知的数据变化时你想要的呢?

这次的问题不就是很典型的例子吗。

远隔千里之外的人修改了几行代码,就对你的App的行为造成了影响。


2、模块化思维


也许你觉得写一个页面就是一个页面。

但是在我看来,很有可能某一天它就是某个页面的一个组件。

假设有一天你的产品要适配pad端

那么很有可能商品列表页和商品详情页会合并成一个页面:左边是列表右边是详情。

这时候原先独立的详情页就是页面的一个组件了。


image.png


这时候是不是通过构造参数传递商品id会合理很多?


总结


我们从一个实际的bug出发,解释了为什么建议大家通过构造参数进行页面传参。

进而引出了关于日常编码中的一些很具体的思维习惯。

总之很多时候最简单直接的用法可能也是最好的选择。


作者:程序员老刘
来源:juejin.cn/post/7394823316585168933
收起阅读 »

Flutter-实现悬浮分组列表

在本篇博客中,我们将介绍如何使用 Flutter 实现一个带有分组列表的应用程序。我们将通过 CustomScrollView 和 Sliver 组件来实现该功能。 需求 我们需要实现一个分组列表,分组包含固定的标题和若干个列表项。具体分组如下: 水果 动物...
继续阅读 »

在本篇博客中,我们将介绍如何使用 Flutter 实现一个带有分组列表的应用程序。我们将通过 CustomScrollViewSliver 组件来实现该功能。


需求


我们需要实现一个分组列表,分组包含固定的标题和若干个列表项。具体分组如下:



  • 水果

  • 动物

  • 职业

  • 菜谱


每个分组包含若干个项目,例如水果组包含苹果、香蕉等。


效果



实现思路



  1. 定义数据模型:创建 ItemBean 类来表示每个分组的数据。

  2. 构建主页面:使用 CustomScrollViewSliver 组件构建主页面,其中包含多个分组。

  3. 实现固定标题:通过自定义 SliverPersistentHeaderDelegate 实现固定标题。


实现代码


以下是实现代码:


import 'package:flutter/material.dart';

/// 数据源
/// https://github.com/yixiaolunhui/flutter_xy
class ItemBean {
final String groupName;
final List<String> items;

const ItemBean({required this.groupName, this.items = const []});

static List<ItemBean> get groupListData => const [
ItemBean(groupName: '水果', items: [
'苹果', '香蕉', '橙子', '葡萄', '芒果', '梨', '桃子', '草莓', '西瓜', '柠檬',
'菠萝', '樱桃', '蓝莓', '猕猴桃', '李子', '柿子', '杏', '杨梅', '石榴', '木瓜'
]),
ItemBean(groupName: '动物', items: [
'狗', '猫', '狮子', '老虎', '大象', '熊', '鹿', '狼', '狐狸', '猴子',
'企鹅', '熊猫', '袋鼠', '海豚', '鲨鱼', '斑马', '长颈鹿', '鳄鱼', '孔雀', '乌龟'
]),
ItemBean(groupName: '职业', items: [
'医生', '护士', '教师', '工程师', '程序员', '律师', '会计', '警察', '消防员', '厨师',
'司机', '飞行员', '科学家', '记者', '设计师', '作家', '演员', '音乐家', '画家', '摄影师'
]),
ItemBean(groupName: '菜谱', items: [
'红烧肉', '糖醋排骨', '宫保鸡丁', '麻婆豆腐', '鱼香肉丝', '酸辣汤', '蒜蓉菠菜', '回锅肉', '水煮鱼', '烤鸭',
'蛋炒饭', '蚝油生菜', '红烧茄子', '西红柿炒鸡蛋', '油焖大虾', '香菇鸡汤', '酸菜鱼', '麻辣香锅', '铁板牛肉', '干煸四季豆'
]),
];
}

/// 分组列表
class Gr0upListPage extends StatelessWidget {
const Gr0upListPage({super.key});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('分组列表')),
body: CustomScrollView(
slivers: ItemBean.groupListData.map(_buildGr0up).toList(),
),
);
}

Widget _buildGr0up(ItemBean itemBean) {
return SliverMainAxisGr0up(
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: HeaderDelegate(itemBean.groupName),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(_, index) => _buildItemByUser(itemBean.items[index]),
childCount: itemBean.items.length,
),
),
],
);
}

Widget _buildItemByUser(String item) {
return Container(
alignment: Alignment.center,
height: 50,
child: Row(
children: [
const Padding(
padding: EdgeInsets.only(left: 20, right: 10.0),
child: FlutterLogo(size: 30),
),
Text(
item,
style: const TextStyle(fontSize: 16),
),
],
),
);
}
}

class HeaderDelegate extends SliverPersistentHeaderDelegate {
final String title;

const HeaderDelegate(this.title);

@override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return Container(
alignment: Alignment.centerLeft,
color: Colors.grey,
padding: const EdgeInsets.only(left: 20),
height: 40,
child: Text(title, style: const TextStyle(fontSize: 16)),
);
}

@override
double get maxExtent => 40;

@override
double get minExtent => 40;

@override
bool shouldRebuild(covariant HeaderDelegate oldDelegate) {
return title != oldDelegate.title;
}
}

通过以上代码,我们实现了一个简单的 Flutter 分组列表应用。每个分组都有固定的标题,点击标题可以展开或收起组内的项目。希望这篇博客对你有所帮助!
详情 :github.com/yixiaolunhui/flutter_xy


作者:一笑轮回
来源:juejin.cn/post/7388091090350702618
收起阅读 »

Flutter 3.24 发布啦,快来看看有什么更新

2024年立秋,Flutter 3.24 如期而至,本次更新主要包含 Flutter GPU 的预览,Web 支持嵌入多个 Flutter 视图,还有更多 Cupertino 相关库以及 iOS/MacOS 的更新等,特别是 Flutter GPU 的出现,...
继续阅读 »

2024年立秋,Flutter 3.24 如期而至,本次更新主要包含 Flutter GPU 的预览,Web 支持嵌入多个 Flutter 视图,还有更多 Cupertino 相关库以及 iOS/MacOS 的更新等,特别是 Flutter GPU 的出现,可以说它为 Impeller 未来带来了全新的可能,甚至官方还展示了小米如何使用 Flutter 为 SU7 新能源车开发 App 的案例。



可以看到,曾经 Flutter 的初代 PM 强势回归之后,Flutter 再一次迎来了新的春风。



Flutter GPU


其实这算是我对 3.24 最感兴趣的更新,因为 Flutter GPU 真的为 Flutter 提供了全新的可能。


Flutter GPU 是 Impeller 对于 HAL 的一层很轻的包装,并搭配了关于着色器和管道编排的自动化能力,也通过 Flutter GPU 就可以使用 Dart 直接构建自定义渲染器,所以 Flutter GPU 可以扩展到 Flutter HAL 中直接渲染的内容。


当然,Flutter GPU 由 Impeller 支持,但重要的是要记住它不是 Impeller ,Impeller 的 HAL 是私有内部代码与 Flutter GPU 的要求非常不同, Impeller 的私有 HAL 和 Flutter GPU 的公共 API 设计之间是存在一定差异化实现。


而通过 Flutter GPU,如曾经的 Scene (3D renderer) 支持,也可以被调整为基于 Flutter GPU 的全新模式实现,因为 Flutter GPU 的 API 允许完全控制渲染通道附件、顶点阶段和数据上传到 GPU 的过程,这种灵活性对于创建复杂的渲染解决方案(从 2D 角色动画到复杂的 3D 场景)至关重要。



可以想象,通过 Flutter GPU,Flutter 开发者可以更简单地对 GPU 进行更精细的控制,通过与 HAL 直接通信,创建 GPU 资源并记录 GPU 命令,从而最大限度的发挥 Flutter 的渲染能力。



有关 Flutter GPU 相关的,详细可见:《Flutter GPU 是什么?为什么它对 Flutter 有跨时代的意义?》


如果你对 Flutter Impeller 和其着色器感兴趣,也可以看:



MacOS PlatformView


其实官方并没有提及这一部分,但是其实从 3.22 就已经有相关实现,相信很多 Flutter 开发都十分关系 PC 上的 PlatformView 和 Webview 的进展,这里也简单汇总下。


关于 macOS 上的 PlatformView 支持,其实 2022 年中的时候,大概是 3.1.0 就有雏形,但是那时候发现了不少问题,例如:



  • UiKitView 并不适合 macOS ,因为它本质上使用的 iOS 的 UiView ,而 macOS 上需要使用的是 NSView;所以后续推进了 AppKitView 的出现,从 MacOS 的 Darwin 平台视图基类添加派生类,能力与 UiKitView 大致相同,但两者实现分离

  • 3.22 基本就已经完成了 macOS 上 Webview 的接入支持, #132583 PR 很早就提交了,但是因为此时的 PlatformView 实现还不支持手势(触控板滚动)等支持,并且也还存在一些点击问题,所以还存于 block


所以目前 AppKitView 已经有了,相关的实现也已经支持,但是还有一些问题 block 住了,另外目前 MacOS 上在 #6221 关于 WebView 的支持上,还存在:



  • 不支持滚动 API,WKWebView 在 macOS 上不公开 scrollView ,获取和设置滚动位置的代码不起作用

  • 由于 macOS 上的视图结构不同,因此无法设置背景颜色,NSView 没有与 UIView 相同的颜色和不透明度控制,因此设置背景颜色将需要替代实现



官方也表示,在完善 macOS 的同时,随后也将推出适用于 Windows 的 PlatformView 和 WebView。



而目前 macOS 上 PlatformView 的实现,采用的是 Hybrid composition 模式,这个模式看过我以前文章的应该不会陌生,它的实现相对性能开销上会比较昂贵:



因为 Flutter 中的 UI 是在专用的光栅线程上执行,而该线程很少被阻塞,但是当使用 Hybrid composition 渲染PlatformView 时,Flutter UI 继续从专用的光栅线程合成,但 PlatformView 是在平台线程上执行图形操作。



为了光栅化组合内容,Flutter 需要在在其光栅线程和 PlatformView 线程之间执行同步,因此 PlatformView 线程上的任何卡顿或阻塞操作都会对 Flutter 图形性能产生负面影响。


之前在 Mobile 上出现过的 Hybrid composition 闪烁情况,在这上面还是很大可能会出现,例如 #138936 就提到过类似的问题并修复。


另外还有如 #152178 里的情况,如果 debugRepaintRainbowEnabled 为 true ,PlatformView 可能会不会响应点击效果 。


所以,如果你还在等带 PC 上 PlatformView 和 WebView 等的相关支持,那么今年应该会能看到 MacOS 上比较完善的发布


Framewrok


全新 Sliver


3.24 包含了一套可组合在一起以实现动态 App bar 相关行为的全新 Sliver :



SliverPersistentHeader 可以使用这些全新的 Slivers 来实现浮动、固定或者跟随用户滚动而调整大小的 App bar,这些新的 Slivers 与现有的 Slivers 效果类似 SliverAppBar ,但具有更简单的 API 。


例如 PinnedHeaderSliver ,它就可以很便捷地就重现了 iOS 设置应用的 Appbar 的效果:



Cupertino 更新


3.24 优化了 CupertinoActionSheet 的交互效果,现在用手指在 Sheet 的按钮上滑动时,可以有相关的触觉反馈,并且按钮的字体大小和粗细现在与 iOS 相关的原生风格一致。



另外还为 CupertinoButton 添加了新的焦点属性,同时 CupertinoTextField 也可以自定义的 disabled 颜色。



未来 Cupertino 库还会继续推进,本次回归的 PM 主要任务之一就是针对 iOS 和 macOS 进行全新一轮的迭代。



TreeView


two_dimensional_scrollables 发布了全新的 TreeView 以及相关支持,用于构建高性能滚动树,这些滚动树可以随着树的增长向各个方向滚动,TreeSliver 还添加到了用于在一维滑动中的支持。



CarouselView


CarouselView 作为轮播效果的实现,可以包含滑动的项目列表,滚动到容器的边缘,并且 leading 和 trailing item 可以在进出视图时动态更改大小。



其他 Widget 更新


从 3.24 开始,一些非特定的设计核心 Widget 会从 Material 库中被移出到 Widgets 库,包括:



  • Feedback Widget 支持设备的触摸和音频反馈,以响应点击、长按等手势

  • ToggleableStateMixin / ToggleablePainter用于构建可切换 Widget(如复选框、开关和单选按钮)的基类


AnimationStatus 的增强


AnimationStatus 添加了一些全新的枚举,包括:



  • isDismissed

  • isCompleted

  • isRunning

  • isForwardOrCompleted


其中一些已存在于 Animation子类中 如 AnimationControllerCurvedAnimation , 现在除了 AnimationStatus 之外,所有这些状态都可在 Animation 子类中使用。


最后,AnimationController 中添加了 toggle 方法来切换动画的方向。



SelectionArea 更新


SelectionArea 又又又引来更新,本次 SelectionArea 支持更多原生手势,例如使用鼠标单击三次以及在触摸设备上双击,默认情况下,SelectionAreaSelectableRegion 都支持这些新手势。


单击三次



  • 三次单击 + 拖动:扩展段落块中的选择内容。

  • 三次点击:选择单击位置处的段落块。



双击



  • 双击+拖动:扩展字块的选择范围(Android/Fuchsia/iOS 和 iOS Web)。

  • 双击:选择点击位置的单词(Android/Fuchsia/iOS 和 Android/Fuchsia Web)。



Engine


Impeller


为了今年移除 iOS 上的 Skia 支持,Flutter 一直在努力改进 Impeller 的性能和保真度,例如对文本渲染的一系列改进大大提高了表情符号滚动的性能,消除了滚动大量表情符号时的卡顿,这是对 Impeller 文本渲染功能的一次极好的压力测试。


此外,通过解决一系列问题,还在这个版本中大大提高了 Impeller 文本渲染的保真度,特别是文本粗细、间距和字距调整,现在这些在 Impeller 都和 Skia 的文本保真度相匹配。



Android 预览


3.24 里 Android 继续为预览状态 ,由于Android 14 中的一个错误影响了 Impeller 的 PlatformView API 支持,所以本次延长了 Impeller 在 Android 上的预览期。



目前 Android 官方已经修复了该错误,但在目前市面上已经有许多未修复的 Android 版本在运行,所以解决这些问题意味着需要进行额外的 API 迁移,因此需要额外的稳定发布周期,所以本次推迟了将 Impeller 设为默认渲染器的决定。



改进了 downscaled images 的默认设置


从 3.24 开始,图像的默认值 FilterQuality已从 FilterQuality.low 调整为FilterQuality.medium


因为目前看来, FilterQuality.low 会更容易导致图像看起来出现“像素化”效果,并且渲染速度比 FilterQuality.medium 更慢。


Web


Multi-view 支持


Flutter Web 现在可以利用 Multi-view 嵌入,同时将内容渲染到多个 HTML 元素中,核心是不再只是 Full-screen 模式,此功能称为 “embedded mode” 或者 “multi-view”,可灵活地将 Flutter 视图集成到现有 Web 应用中。


在 multi-view 模式下,Flutter Web 应用不会在启动时立即渲染,相反它会等到 host 应用使用 addView 方法添加第一个“视图” ,host 应用可以动态添加或删除这些视图,Flutter 会相应地调整其 Widget 状态。


要启用 multi-view 模式,可以在 flutter_bootstrap.js 文件中的 initializeEngine方法, 通过 multiViewEnabled: true进行设置。


// flutter_bootstrap.js
{{flutter_js}}
{{flutter_build_config}}

_flutter.loader.load({
onEntrypointLoaded: async function onEntrypointLoaded(engineInitializer) {
let engine = await engineInitializer.initializeEngine({
multiViewEnabled: true, // Enables embedded mode.
});
let app = await engine.runApp();
// Make this `app` object available to your JS app.
}
});

设置之后,就可以通过 JavaScript 管理视图,将它们添加到指定的 HTML 元素并根据需要将其移除,每次添加和移除视图都会触发 Flutter 的更新,从而实现动态内容渲染。


// Adding a view...
let viewId = app.addView({
hostElement: document.querySelector('#some-element'),
});

// Removing viewId...
let viewConfig = flutterApp.removeView(viewId);

另外视图的添加和删除通过类的 WidgetsBinding didChangeMetrics 去管理和感知:


@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_updateViews();
}

@override
void didUpdateWidget(MultiViewApp oldWidget) {
super.didUpdateWidget(oldWidget);
// Need to re-evaluate the viewBuilder callback for all views.
_views.clear();
_updateViews();
}

@override
void didChangeMetrics() {
_updateViews();
}

Map<Object, Widget> _views = <Object, Widget>{};

void _updateViews() {
final Map<Object, Widget> newViews = <Object, Widget>{};
for (final FlutterView view in WidgetsBinding.instance.platformDispatcher.views) {
final Widget viewWidget = _views[view.viewId] ?? _createViewWidget(view);
newViews[view.viewId] = viewWidget;
}
setState(() {
_views = newViews;
});
}



另外通过 final int viewId = View.of(context).viewId; 也可以识别视图, viewId 可用于唯一标识每个视图。



更多可见 docs.flutter.dev/platform-in…



iOS


Swift Package Manager 初步支持


一直以来 Flutter 都是使用 CocoaPods 来管理 iOS 和 macOS 依赖项,而 Flutter 3.24 增加了对 Swift Package Manager 的早期支持,这对于 Flutter 来说,好处就是:



  • Flutter 的 Plugin 可以更贴近 Swift 生态

  • 简化 Flutter 安装环境,Xcode 本身就是包含 Swift Package Manager,如果 Flutter 的项目使用 Swift Package Manager,则完全无需安装 Ruby 和 CocoaPods 等环境


而从目前的官方 Package 上看,#146922 上需要迁移支持的 Package 大部分都已经迁移完毕,剩下的主要文档和脚本部分的支持。




更多详细可见 《Flutter 正在迁移到 Swift Package Manager ,未来会弃用 CocoaPods 吗?》



Ecosystem


SharedPreferences 更新


sharedpreferences 插件添加了两个新 API :SharedPreferencesAsync 和 SharedPreferencesWithCache,最重要的变化是 Android 实现使用 PreferencesDataStore 而不是 SharedPreferences


SharedPreferencesAsync 允许用户直接调用平台来获取设备上保存的最新偏好设置,但代价是异步,速度比使用缓存版本慢一点。这对于可以由其他系统或隔离区更新的偏好设置很有用,因为更新缓存会使缓存失效。


SharedPreferencesWithCache 建立在 SharedPreferencesAsync 之上,允许用户同步访问本地缓存的偏好设置副本。这与旧 API 类似,但现在可以使用不同的参数多次实例化。


这些新 API 旨在将来取代当前的 SharedPreferences API。但是,这是生态系统中最常用的插件之一,我们知道生态系统需要一些时间才能切换到新 API。


DevTools 和 IDE


DevTools Performance 工具新增 Rebuild Stats功能,可以捕获有关在应用中甚至在特定 Flutter 框架中构建 Widget 的次数的信息。


image-20240807052902246


另外,本次还对 Network profilerFlutter Deep Links 等工具进行了完善和关键错误修复,并进行了一些常规改进,如 DevTools 在 VS Code 窗口内打开DevTools在 Android Studio 工具窗口内打开


image-20240807052955842



3.24 版本还对 DevTools Extensions 进行了一些重大改进,现在可以在调试 Dart 或 Flutter 测试时使用 DevTools Extensions ,甚至可以在不调试任何内容而只是在 IDE 中编写代码时使用。


最后


不得不说 Flutter 在新技术投资和跟进上一直很热衷,不管是之前的 WASM Native ,还是 Flutter GPU 的全新尝试,甚至 RN 还在挣扎 Swift Package Manager 的支持时,Flutter 已经初步落地 Swift Package Manager,还有类似 sharedpreferences 跟进到 PreferencesDataStore 等,都可以看出 Flutter 的技术迭代还是相对激进的。


本次更新,Flutter team 也展示了案例:



  • 小米的一个小团队如何以及为何使用 Flutter 为 SU7 新能源车开发 App :flutter.dev/showcase/xi…

  • 法国铁路公司SNCF Connect 在欧洲的案例,它与奥运会合作,为使数百万游客能够在奥运会期间游览法国

  • Whirlpool 正在利用 Flutter 在巴西探索新的销售渠道

  • ·····


另外,2024 年 Fluttercon 欧洲举办了首届 Flutter 和 Dart 生态系统峰会,具体讨论了如:



  • FFI 和 jnigen/ffigen 缺少更多示例和文档

  • method channels 调试插件的支持

  • 合并 UI 和平台线程的可能性

  • 研究减轻插件开发负担的策略

  • 解决包装生态系统碎片化问题


而接下来 9 月份 Fluttercon USA 也将继续在纽约召开深入讨论相关主题,可以看到 Flutter 正在进一步开放和听取社区开发者的意见并改进,Flutter 虽然还有很多坑需要补,但是它也一直在努力变得更好。


所以,骚年,你打算更新 3.24 吃螃蟹了吗?还是打算等 3.24.6


作者:恋猫de小郭
来源:juejin.cn/post/7399952146236571685
收起阅读 »

好消息!uniapp也能开发鸿蒙了,但坏消息是……

相信不少前端从业者一听uniapp支持开发鸿蒙Next后非常振奋。猫林老师作为7年前端er也是非常激动,第一时间体验了下。在这里也给大家分享一下我的看法 uniapp开发鸿蒙优势 对于前端开发者而言,几乎无需增加额外的学习成本 一套代码,通用在Androi...
继续阅读 »

相信不少前端从业者一听uniapp支持开发鸿蒙Next后非常振奋。猫林老师作为7年前端er也是非常激动,第一时间体验了下。在这里也给大家分享一下我的看法



uniapp开发鸿蒙优势



  1. 对于前端开发者而言,几乎无需增加额外的学习成本

  2. 一套代码,通用在Android、iOS、HarmonyOS,小公司狂喜(可以只招一位牛马完成所有工作)

  3. 能迅猛将现有项目移植到鸿蒙平台,迅速掌握鸿蒙用户流量以及争取政府补贴

  4. 以及更多猫林老师没想到的优点(抱歉,实在憋不出来了)


uniapp开发鸿蒙缺点



  • 这真的是可以大吐特吐的地方了,uniapp目前支持鸿蒙的方案是web渲染方案,也就是说相当于利用鸿蒙内部的webview显示一个网页

  • 那这有什么不好呢?



    1. 首先是渲染性能达不到原生、其次是逻辑代码是JS实现,而JS引擎慢,这就导致启动速度和运行速度弱于原生

    2. JS与原生UI层或者原生API通信可能会卡顿



  • 其次是目前仅支持vue3,对于还在守着vue2的古早前端也不友好

  • 以上结论来自uniapp官网说明,如下图


uni.png



  • 因此猫林老师不认为目前的uniapp适合鸿蒙开发,所以如果有志于抢占鸿蒙风口的同学,可以坚定信心了,还是得好好学习鸿蒙原生开发。


uniapp未来会好吗?



  • 上述缺点其实DCloud官方(uniapp所属)也意识到了,所以一直在打造新一代的uniapp,也即uni-app x

  • 这套新平台追求解决所有跨平台开发框架性能无法媲美原生的痛点,通过不同平台编译成不同语言来实现:在iOS平台编译为swift、在Android平台编译为kotlin、在Web和小程序平台编译为js、在鸿蒙next平台上编译为ArkTS。就相当于你用vue的语法写了原生的代码。

  • 因此,未来的uniapp还是非常值得期待的!

  • 但现阶段,虽然uni-app x也已经对外发布,但是对于鸿蒙的支持还在不断的完善。并且鸿蒙自身也在不断的升级迭代,所以现阶段的uni-app x暂时还是无法展现完整的鸿蒙开发之美。期望未来能越来越好,为鸿蒙生态提供强有效的生产力。


总结


uniapp支持鸿蒙是一个好消息,未来也值得期待。但是现阶段用来作为找鸿蒙开发岗位的工作还是不太合适。


作者:猫林老师
来源:juejin.cn/post/7397323478851158050
收起阅读 »

还学鸿蒙原生?vue3 + uniapp 可以直接开发鸿蒙啦!

Hello,大家好,我是 Sunday 7月20号,uniapp 官网“悄咪咪”的上线了 uniapp 开发鸿蒙应用 的文档,算是正式开启了 Vue3 + uniapp 开发鸿蒙应用 的时代。 开发鸿蒙的前置准备 想要使用 uniapp 开发鸿蒙,我们需要具...
继续阅读 »

Hello,大家好,我是 Sunday


7月20号,uniapp 官网“悄咪咪”的上线了 uniapp 开发鸿蒙应用 的文档,算是正式开启了 Vue3 + uniapp 开发鸿蒙应用 的时代。



开发鸿蒙的前置准备


想要使用 uniapp 开发鸿蒙,我们需要具备三个条件:



  1. DevEco-Studio 5.0.3.400 以上(下载地址:https://developer.huawei.com/consumer/cn/deveco-studio/

  2. 鸿蒙系统版本 API 12 以上 (DevEco-Studio有内置鸿蒙模拟器)

  3. HBuilderX-alpha-4.22 以上


PS: 这里不得不吐槽一下,一个 DevEco-Studio 竟然有 10 个 G......




安装好之后,我们就可以通过 开发工具 运行 示例代码



运行时,需要用到 鸿蒙真机或者模拟器。但是这里需要 注意: Windows系统需要经过特殊配置才可以启动,mac 系统最好保证系统版本在 mac os 12 以上


windows 系统配置方式(非 windows 用户可跳过):


打开控制面板 - 程序与功能 - 开启以下功能



  1. Hyper-V

  2. Windows 虚拟机监控程序平台

  3. 虚拟机平台


注意: 需要win10专业版或win11专业版才能开启以上功能,家庭版需先升级成专业版或企业版



启动鸿蒙模拟器


整个过程分为三步(中间会涉及到鸿蒙开发者申请):



  1. 下载 uni-app 鸿蒙离线SDK template-1.3.4.tgz (下载地址:https://web-ext-storage.dcloud.net.cn/uni-app/harmony/zip/template-1.3.4.tgz

  2. 解压刚下载的压缩包,将解压后的模板工程在 DevEco-Studio 中打开




  1. 等待 Sync 结束,再 启动鸿蒙模拟器 或 连接鸿蒙真机(如无权限,则需要申请(一般 3 个工作日),申请地址:https://developer.huawei.com/consumer/cn/activity/201714466699051861/signup



配置 HBuilderX 吊起 DevEco-Studio


打开HBuilderX,点击上方菜单 - 工具 - 设置,在出现的弹窗右侧窗体新增如下配置



注意:值填你自己的 DevEco-Studio 启动路径


"harmony.devTools.path" : "/Applications/DevEco-Studio.app"


创建 uni-app 工程



  1. BuilderX 新建一个空白的 uniapp 项目,选vue3

  2. 在 manifest.json 文件中配置鸿蒙离线SDK路径(SDK 路径可在 DevEco-Studio -> Preferences(设置) z中获取)



编辑 manifest.json 文件,新增如下配置:



然后点击 运行到鸿蒙即可



总结


这样我们就有了一个初始的鸿蒙项目,并且可以在鸿蒙模拟器上运行。关于更多 uniapp 开发鸿蒙的 API,大家可以直接参考 uniapp 官方文档:https://zh.uniapp.dcloud.io/tutorial/harmony/dev.html#nativeapi


作者:程序员Sunday
来源:juejin.cn/post/7395964591799025679
收起阅读 »

uniapp适配android、ios的引导页、首页布局

uniapp适配Android、Ios的引导页和首页布局 真是很久没来掘金写文章了,最近一直在学习Nest和Next这些后端知识,忙只是一方面,更多的还是懒吧。其实今年来北京工作之后,完全独立挑大梁来写app收获还是蛮多的,但是我一般忙完就完事,着急学习自己...
继续阅读 »

uniapp适配Android、Ios的引导页和首页布局



真是很久没来掘金写文章了,最近一直在学习Nest和Next这些后端知识,忙只是一方面,更多的还是懒吧。其实今年来北京工作之后,完全独立挑大梁来写app收获还是蛮多的,但是我一般忙完就完事,着急学习自己的东西去,没有把工作中遇到的一些问题及时总结。这点感觉很不好,以后尽量把工作中遇到的有价值的问题总结下来,也算是给自己这段时间工作的复习,也能锻炼自己的表达能力。



引导页


原型图和需求


微信截图_20240722143529.png



需求大致是这样:一共有三页,每页有2-3组图片,产品想要炫酷的视觉效果



我接收到需求后,首先想的是gif图,于是让UI帮我做了一张12帧的gif,大家来感受一下效果


01.gif



不知道大家感受怎么样,放到手机来模拟的时候有些模糊、有些卡顿,且占用空间很大,一张12帧的图片已经20M+,
整个应用不过才30M的情况下,绝对接受不了这种情况,于是我就放弃的gif,想要用代码来实现。



思路


留给我的开发时间并不多,只有半天,自己本身css能力一般,按照gif这样估计最多做出来一页,所以我和产品决定阉割掉一部分动效,做三页。



  • UI负责把每条图片列表切图给我

  • 引导页用swiper实现,这样页面切换动画可以省时间

  • 第一页水平做动画两两一组,交替实现动画

  • 第二页垂直做动画,交替实现

  • 第三页原图和AI图在一个父盒子下,原图动态改变宽度来实现交替播放

  • 每页文字和按钮通过position:fixed置底

  • 最后一页手动加上滑动事件,可以不点击按钮进入首页


代码实现



  • template布局


<view class="swiperLayout">
<swiper
:current="current"
class="swiper"
duration="350"
@change="change"
:indicator-active-color=" '#FFF272' "
:indicator-color="'#ccc'"
indicator-dots="true"
>

<swiper-item class="swiperItem">
<view class="itemLayout">
<image
class="img an1"
src="@/static/guide/guide1_1.png"
mode="scaleToFill"
/>

<image
class="img an2"
src="@/static/guide/guide1_2.png"
mode="scaleToFill"
/>

<image
class="img an1"
src="@/static/guide/guide1_3.png"
mode="scaleToFill"
/>

<image
class="img an2"
src="@/static/guide/guide1_4.png"
mode="scaleToFill"
/>

<view class="buttonBox">
<view class="title">海量模板</view>
<view class="button" @click="next(1)">下一步</view>
</view>
</view>
</swiper-item>
<swiper-item class="swiperItem">
<view class="itemLayout">
<view class="guide2Box">
<image
class="img2 an3"
src="@/static/guide/guide2_1.png"
mode="scaleToFill"
/>

<image
class="img2 an4"
src="@/static/guide/guide2_2.png"
mode="scaleToFill"
/>

<image
class="img2 an3"
src="@/static/guide/guide2_3.png"
mode="scaleToFill"
/>

</view>
<view class="buttonBox">
<view class="title">5000+云端照片存储</view>
<view class="button" @click="next(2)">下一步</view>
</view>
</view>
</swiper-item>
<swiper-item
class="swiperItem"
@touchstart="handlerStart($event)"
@touchmove="handerMove($event)"
>

<view class="itemLayout">
<view class="guide3">
<-- img3动态改变自己的宽度,来实现动画效果 -->
<image
class="img3 an5 z"
src="@/static/guide/guide3_1.png"
mode="aspectFill"
/>

<image
class="img4 "
src="@/static/guide/guide3_2.png"
mode="heightFix"
/>

</view>
<view class="buttonBox">
<view class="title">高清照片,无水印无广告</view>
<view class="button" @click="toIndex">继续</view>
</view>
</view>
</swiper-item>
</swiper>
</view>


  • css部分


.swiper {
width: 100vw;
height: 100vh;
background: #000;
.swiperItem {
.itemLayout {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 60rpx;
.img {
width: 220vw;
height: 35vw;
margin: 20rpx 0 0rpx 0;
}
.img2 {
width: 30vw;
height: 256vw;
}
.title {
color: $themeColor;
margin-top: 40rpx;
text-align: center;
font-size: 36rpx;
font-weight: 600;
margin-bottom: 40rpx;
}
.button {
background: $themeColor;
color: #000;
height: 88rpx;
line-height: 88rpx;
width: 88%;
text-align: center;
border-radius: 48rpx;
font-size: 32rpx;
font-weight: 600;
}
}
.guide2Box{
display: flex;
justify-content: space-evenly;
width: 100%;
overflow: hidden;
height: 70vh;
}
}
}

// 动画1 执行三秒 匀速 无限次 镜像执行
.an1 {
animation: guide1 3s linear infinite alternate-reverse ;
}

// 水平X轴正向
@keyframes guide1 {
from {
transform: translateX(0);
}
50% {
transform: translateX(200rpx);
}
to {
transform: translateX(400rpx);
}
}

.an2 {
animation: guide2 3s linear infinite alternate-reverse ;
}
// 水平X轴负向
@keyframes guide2 {
from {
transform: translateX(0);
}
50% {
transform: translateX(-200rpx);
}
to {
transform: translateX(-400rpx);
}
}

.an3 {
animation: guide3 3s linear infinite alternate-reverse ;
}
// 水平正向 但是起始点要给负数 不然会有空缺的部分
@keyframes guide3 {
from {
transform: translateY(-500rpx);
}
50% {
transform: translateY(-250rpx);
}
to {
transform: translateY(0rpx);
}
}

.an4 {
animation: guide4 3s linear infinite alternate-reverse ;
}
// 水平负向
@keyframes guide4 {
from {
transform: translateY(0);
}
50% {
transform: translateY(-250rpx);
}
to {
transform: translateY(-500rpx);
}
}
.buttonBox{
position: fixed;
bottom: 120rpx;
width: 80vw;
display: flex;
flex-direction: column;
align-items: center;
z-index: 999;
}
// 最后一页动画 父盒子开启相对定位
.guide3{
position: relative;
width: 100%;
height: 100%;
// 两张图片都开始绝对定位 一左一右分布
.img3{
position: absolute;
top: 0;
left: 0;
height: 147vw;
border-right: 12rpx solid #fff;
}
.img4{
position: absolute;
top: 0;
right: 0;
height: 147vw;
}
}
// img3 缩小自己的宽度来实现动画
.an5 {
animation: changeImg 2s linear infinite alternate-reverse;
}
@keyframes changeImg {
from {
width: 0%;
}
to {
width: 100%;
}
}

.z{
z-index: 99;
}


  • js部分


data() {
return {
current: 0,
// 触摸事件用到的数据
touchInfo: {
touchX: "",
touchY: "",
},
};
},
methods: {
next(num) {
this.current = num;
},
change(e) {
this.current = e.detail.current;
},
toIndex() {
uni.switchTab({ url: "/pages/index/index" });
},
handlerStart(e) {
let { clientX, clientY } = e.changedTouches[0];
this.touchInfo.touchX = clientX;
this.touchInfo.touchY = clientY;
},
handerMove(e) {
let { clientX, clientY } = e.changedTouches[0];
let diffX = clientX - this.touchInfo.touchX,
diffY = clientY - this.touchInfo.touchY,
absDiffX = Math.abs(diffX),
absDiffY = Math.abs(diffY),
type = "";
if (absDiffX > 50 && absDiffX > absDiffY) {
type = diffX >= 0 ? "right" : "left";
}
if (absDiffY > 50 && absDiffX < absDiffY) {
type = diffY < 0 ? "up" : "down";
}
if(type === 'left'){
this.toIndex()
}
},
},

最终效果


动画2.gif


首页布局


原型图和需求



  • 画风


微信截图_20240722144340.png



  • 贴纸


微信截图_20240722143558.png



  • 换脸


微信截图_20240722144351.png



上面三图均为UI设计。首页的模板接口截止到目前(7.22)一共三种类型:styler(画风)、sticker(贴纸)、face_swap(换脸),本来按照UI的设计来看,每个分类的样式应该是固定写死的,我只需要v-for去不同的组件就可以,正当我写了一半时,很快老板的需求又下来:每个分类可能会杂糅在一起。说白了就是某个分类里可能既有画风、又有换脸、又有贴纸



思路



  • 分析需求



在一个父组件中渲染所有的数据,根据不同的type 进入不同的子组件,三个子组件分别对应画风、贴纸、换脸,其中贴纸数据中有一个mode字段,根据mode展示轮播、九宫格、一大八小的布局,这其中一大八小最不好实现。



一大八小的布局



  • 将数据中的九张模板图片进行分组(剔除第一张,因为第一张要做“一大”),分为两组布局是上下分布(display:flex)实现,同时将第一张和分组的view盒子的父元素也要开启display:flex

  • 编译到chrome调试 看html结构


Snipaste_2024-07-22_15-25-39.png



  • 代码


 <scroll-view class="scroll_view" scroll-x="true">
<image
class="img"
:src="sceneItem.json_content.cover_image_list[0].path"
mode="scaleToFill"
/>

<view>
<view
class="Item_2"
v-for="(Item, index) in columnData"
:key="index"
>

<view v-for="item in Item" :key="item.id">
<image
class="ss"
:src="item.path"
mode="scaleToFill"
/>

</view>
</view>
</view>

</scroll-view>
...
computed:{
columnData() {
if (this.sceneItem.json_content.display_mode === "2") {
const setData = this.sceneItem.json_content.cover_image_list.filter(
(item, index) => index > 0
);
const resultArray = setData.reduce(
(acc, cur, index) => {
const targetIndex = index % 2;
acc[targetIndex].push(cur);
return acc;
},
Array.from(Array(2), () => [])
);
return resultArray;
}
},
}
...
::v-deep .uni-scroll-view-content {
display: flex;
}
.scroll_view {
white-space: nowrap;
.img {
min-width: 324rpx;
height: 324rpx;
border-radius: 24rpx;
margin-right: 24rpx;
}
.Item {
display: inline-block;
.img {
width: 324rpx;
height: 324rpx;
border-radius: 24rpx;
margin-right: 24rpx;
}
}
.Item_2 {
display: flex;
.ss {
width: 158rpx;
height: 158rpx;
margin-right: 12rpx;
border-radius: 16rpx;
}
}
}

实现效果


动画3.gif


作者:你听得到11
来源:juejin.cn/post/7394005582774960182
收起阅读 »

Flutter 为什么没有一款好用的UI框架?

哈喽,我是老刘 前两天,系统给我推送了一个问题。 我理解提问者真正想问的是:有没有一个不用学习那么多UI组件和渲染知识,可以简单快速搭建UI的东西。 Flutter 包括原生开发,为什么需要考虑那么多细节,不能做的简单一些? 首先,我们需要明白Flutter...
继续阅读 »

哈喽,我是老刘

前两天,系统给我推送了一个问题。


image.png


我理解提问者真正想问的是:有没有一个不用学习那么多UI组件和渲染知识,可以简单快速搭建UI的东西。


Flutter 包括原生开发,为什么需要考虑那么多细节,不能做的简单一些?


首先,我们需要明白Flutter的定位。

Flutter不是一个简单的甜品,而是一个能支撑大型系统开发的工程级框架。

这种定位和原生框架的定位是相当的。

因此,它要求整个框架有足够的灵活性,能适用于尽可能多的场景。


image.png


那么,如何提供足够的灵活性呢?

答案是让整个框架尽可能多的细节是可控的。

这就需要把整个框架的功能拆分的更细,提供的配置项足够多。

然而,这样的缺点就是开发起来会比较麻烦,需要控制很多细节。

因此,我们可以看到Flutter的组件拆分的很细,甚至有类似Padding这样专门负责缩进的组件,而且每个组件都有很多的配置参数。


Flutter配合Material组件库本身本就非常优秀的UI框架


虽然Flutter的灵活性带来了开发上的复杂性,但Flutter配合Material组件库本身就是一个非常优秀的UI框架。


image.png


Material组件库提供了丰富的预设组件,这些组件遵循Material Design指南,可以帮助开发者快速搭建出既美观又符合设计规范的UI界面。

使用Material组件库,开发者可以不必从头开始设计每一个UI元素,而是可以直接使用现成的组件,如按钮、对话框、卡片等,这些组件都有良好的交互和动画效果。

此外,Material组件库还提供了主题支持,开发者可以通过简单的配置,快速应用统一的风格到整个应用中。

因此,虽然Flutter的灵活性可能让初学者感到有些复杂,但配合Material组件库,Flutter实际上提供了一个非常高效和优秀的UI开发体验。


大型项目的正确打开方式


即便是Material组件库,它的设计是需要考虑应对各种不同类型app开发的,但是针对一个具体的项目,我们大多数时候不需要这样高的灵活性。

所以,这种情况下直接用Flutter提供的组件效率会比较低。

解放方法就是针对特定的项目做组件封装。


以我目前维护的项目为例,我们项目中所有的对话框都是相同的偏绿色调,圆角半径20,按钮大小固定,标题、详情的字体、字号也固定。

简单来说,就是所有的UI细节都是固定的,只是不同的dialog需要填充的文字不同。


这时候,我们就会定义一个自己的Dialog组件,只需要使用者传入标题和内容,以及设置按钮的回调即可。

UI的其他地方也是如此,比如页面框架、在多个页面都能用到的用户卡片、商品卡片等等。


当你的整个App大部分都是基于这些自定义组件进行搭积木式的开发,那开发效率是不是比找一些通用的UI框架更高呢?


总结


总而言之,Flutter因为它的工程级框架定位需要提供高度的灵活性,而这往往会导致开发细节的复杂性。

但是,通过针对具体项目的组件封装,我们可以大大提高开发效率,同时保持UI的一致性和项目的特定需求。

所以,与其寻找一个通用的UI框架,不如根据项目的具体需求进行自定义组件的开发。


如果看到这里的同学有学习Flutter的兴趣,欢迎联系老刘,我们互相学习。

点击免费领老刘整理的《Flutter开发手册》,覆盖90%应用开发场景。

可以作为Flutter学习的知识地图。

覆盖90%开发场景的《Flutter开发手册》


作者:程序员老刘
来源:juejin.cn/post/7387001928209170447
收起阅读 »

uniapp下各端调用三方地图导航

技术栈开发框架: uniappvue 版本: 2.x需求使用uniapp在app端(Android,IOS)中显示宿主机已有的三方导航应用,由用户自主选择使用哪家地图软件进行导航,选择后,自动将目标地址设为终点在导航页面。 使用uniapp在微信小程序中调用微...
继续阅读 »

技术栈

  • 开发框架: uniapp
  • vue 版本: 2.x

需求

使用uniappapp端(Android,IOS)中显示宿主机已有的三方导航应用,由用户自主选择使用哪家地图软件进行导航,选择后,自动将目标地址设为终点在导航页面。 使用uniapp微信小程序中调用微信内置地图导航。

实现

微信小程序调用微信内置地图导航

使用uni.openLocation()方法可直接调用,微信比较简单

uni文档:uniapp.dcloud.net.cn/api/locatio…

传值字段

名称说明是否必传
latitude纬度,范围为-90~90,负数表示南纬,使用 gcj02 国测局坐标系
longitude经度,范围为-180~180,负数表示西经,使用 gcj02 国测局坐标系
name位置名称非必传,但不传不显示目标地址名称
address地址的详细说明非必传,但不传不显示目标地址名称详情

具体代码

经纬度需转为float数据类型

uni.openLocation({
latitude: parseFloat('地址纬度'),
longitude: parseFloat('地址经度'),
name: ‘地址名称,
address: '地址详情',
success: function (res) {
console.log('打开系统位置地图成功')
},
fail: function (error) {
console.log(error)
}
})


app端调用宿主机三方地图导航

步骤:

  1. 获取宿主机已安装的三方地图应用并显示,没有安装提示宿主机。
  2. 根据宿主机选择的三方地图,打开对应的三方地图进行导航。

使用plus调用原生API知识点:

  1. 获取宿主机系统环境

uniapp文档:uniapp.dcloud.net.cn/api/system/…

使用uniappuni.getSystemInfoSync().platform方法获取宿主机系统环境,结果为androidios

  1. 获取宿主机是否安装某个应用

H5产业联盟文档:http://www.html5plus.org/doc/zh_cn/r…

使用H5产业联盟中的 plus.runtime.isApplicationExist来判断宿主机是否安装指定应用,已安装返回True

Android平台需要通过设置appInf的pname属性(包名)进行查询。 iOS平台需要通过设置appInf的action属性(Scheme)进行查询,在iOS9以后需要添加白名单才可查询,在manifest.json文件plus->distribute->apple->urlschemewhitelist节点下添加(如urlschemewhitelist:["weixin"])。

调用示例

// Android
plus.runtime.isApplicationExist({pname: 'com.autonavi.minimap'})
// iOS
plus.runtime.isApplicationExist({action: 'iosamap://'})

  1. 调用系统级选择菜单显示已安装地图列表

H5产业联盟文档:http://www.html5plus.org/doc/zh_cn/n…

调用示例

plus.nativeUI.actionSheet({ //选择菜单
title: "选择地图应用",
cancel: "取消",
buttons: [
{title: '1'},
{title: '2'}
]
}, function (e) {
console.log("您点击的是第几个:"+e.index)
})

  1. 打开三方某个应用

H5产业联盟文档:http://www.html5plus.org/doc/zh_cn/r…

调用示例

// Android
plus.runtime.openURL('三方应用地址', function(res){
// todo...
}, 'com.xxx.xxxapp');

// ios
plus.runtime.openURL('三方应用地址', function(res){
// todo...
});

具体代码:

<template>
<view @click.stop="handleNavigation">导航view>
template>

<script>
...
data() {
return {
// 目标纬度
latitude: '',
// 目标经度
longitude: '',
// 目标地址名称
name: '',
// 目标地址详细信息
address: '',
// 我自己的位置经纬度(百度地图需要传入自己的经纬度进行导航)
selfLocation: {
latitude: '',
longitude: ''
}
}
},
methods: {
handleNavigation() {
const _this = this
if (!this.latitude || !this.longitude || !this.name) return
// 微信
// #ifdef MP-WEIXIN
let _obj = {
latitude: parseFloat(this.latitude),
longitude: parseFloat(this.longitude),
name: this.name,
}
if (this.address) {
_obj['address'] = this.address
}
uni.openLocation({
..._obj,
success: function (res) {
console.log('打开系统位置地图成功')
},
fail: function (error) {
console.log(error)
}
})
// #endif

// #ifdef APP-PLUS
// 判断系统安装的地图应用有哪些, 并生成菜单按钮
let _mapName = [
{title: '高德地图', name: 'amap', androidName: 'com.autonavi.minimap', iosName: 'iosamap://'},
{title: '百度地图', name: 'baidumap', androidName: 'com.baidu.BaiduMap', iosName: 'baidumap://'},
{title: '腾讯地图', name: 'qqmap', androidName: 'com.tencent.map', iosName: 'qqmap://'},
]
// 根据真机有的地图软件 生成的 操作菜单
let buttons = []
let platform = uni.getSystemInfoSync().platform
platform === 'android' && _mapName.forEach(item => {
if (plus.runtime.isApplicationExist({pname: item.androidName})) {
buttons.push(item)
}
})
platform === 'ios' && _mapName.forEach(item => {
console.log(item.iosName)
if (plus.runtime.isApplicationExist({action: item.iosName})) {
buttons.push(item)
}
})
if (buttons.length) {
plus.nativeUI.actionSheet({ //选择菜单
title: "选择地图应用",
cancel: "取消",
buttons: buttons
}, function (e) {
let _map = buttons[e.index - 1]
_this.openURL(_map, platform)
})
} else {
uni.showToast({
title: '请安装地图软件',
icon: 'none'
})
return
}
// #endif
},

// 打开第三方程序实际应用
openURL(map, platform) {
let _defaultUrl = {
android: {
"amap": `amapuri://route/plan/?sid=&did=&dlat=${this.latitude}&dlon=${this.longitude}&dname=${this.name}&dev=0&t=0`,
'qqmap': `qqmap://map/routeplan?type=drive&to=${this.name}&tocoord=${this.latitude},${this.longitude}&referer=fuxishan_uni_client`,
'baidumap': `baidumap://map/direction?origin=${this.selfLocation.latitude},${this.selfLocation.longitude}&destination=name:${this.name}|latlng:${this.latitude},${this.longitude}&coord_type=wgs84&mode=driving&src=andr.baidu.openAPIdemo"`
},
ios: {
"amap": `iosamap://path?sourceApplication=fuxishan_uni_client&dlat=${this.latitude}&dlon=${this.longitude}&dname=${this.name}&dev=0&t=0`,
'qqmap': `qqmap://map/routeplan?type=drive&to=${this.name}&tocoord=${this.latitude},${this.longitude}&referer=fuxishan_uni_client`,
'baidumap': `baidumap://map/direction?origin=${this.selfLocation.latitude},${this.selfLocation.longitude}&destination=name:${this.name}|latlng:${this.latitude},${this.longitude}&mode=driving&src=ios.baidu.openAPIdemo`
}
}
let newurl = encodeURI(_defaultUrl[platform][map.name]);
console.log(newurl)
plus.runtime.openURL( newurl, function(res){
console.log(res)
uni.showModal({
content: res.message
})
}, map.androidName ? map.androidName : '');
}

}
script>

最终效果图

  1. 微信

微信地图.jpg

  1. app端 https://blog.zhanghaoran.ren/image/1691035758346Screenshot_2023-08-03-12-08-23-298_com.zzrb.fuxishanapp.jpg

最后

参考链接: H5产业联盟:http://www.html5plus.org/doc/h5p.htm… uniapp: uniapp.dcloud.net.cn/api/ 百度、高德、腾讯地图,三方APP调用其的文档。

本文初发于:blog.zhanghaoran.ren/article/htm…


作者:ZhangHaoran
来源:juejin.cn/post/7262941534528700453

收起阅读 »

uni-app 集成推送

研究了几天,终于是打通了uni-app的推送,本文主要针对的是App端的推送开发过程,分为在线推送和离线推送。我们使用uni-app官方推荐的uni-push2.0。官方文档准备工作:开通uni-push功能勾选uniPush2.0点击"配置"填写表单&nbs...
继续阅读 »

研究了几天,终于是打通了uni-app的推送,本文主要针对的是App端的推送开发过程,分为在线推送和离线推送。我们使用uni-app官方推荐的uni-push2.0。官方文档

准备工作:开通uni-push功能

image.png

  1. 勾选uniPush2.0
  2. 点击"配置"
  3. 填写表单

image.png 关联服务空间说明:

uni-push2.0需要开发者开通uniCloud。不管您的业务服务器是否使用uniCloud,但实现推送,就要使用uniCloud服务器。

  • 如果您的后台业务使用uniCloud开发,那理解比较简单。
  • 如果您的后台业务没有使用uniCloud,那么也需要在uni-app项目中创建uniCloud环境。在uniCloud中写推送逻辑,暴露一个接口,再由业务后端调用这个推送接口。

在线推送

以上操作配置好了以后,回到HBuilderX。

因为上面修改了manifest.json配置,一定要重新进行一次云打包(打自定义调试基座和打正式包都可以)后才会生效。

客户端代码

我这边后端使用的是传统服务器,未使用云开发。要实现推送,首先需要拿到一个客户端的唯一标识,使用uni.getPushClientId API链接地址

onLaunch() {
uni.getPushClientId({
success: (res) => {
let push_clientid = res.cid
console.log('客户端推送标识:', push_clientid)
// 保存在全局,可以在进入app登录账号后调用一次接口将设备id传给后端
this.$options.globalData.pushClientId = push_clientid
// 一进来就掉一次接口把push_clientid传给后端
this.$setPushClientId(push_clientid).then(res => {
console.log('[ set pushClientId res ] >', res)
})
},
fail(err) {
console.log(err)
}
})
}

客户端监听推送消息

监听推送消息的代码,需要在收到推送消息之前被执行。所以应当写在应用一启动就会触发的应用生命周期onLaunch中。

//文件路径:项目根目录/App.vue
export default {
onLaunch: function() {
console.log('App Launch')
uni.onPushMessage((res) => {
console.log("收到推送消息:",res) //监听推送消息
})
},
onShow: function() {
console.log('App Show')
},
onHide: function() {
console.log('App Hide')
}
}

服务端代码

  1. 鼠标右击项目根目录,依次执行

image.png

  1. 然后右击uniCloud目录,选择刚开始创建的云服务空间

image.png

  1. 在cloudfunctions目录右击,新建云函数/云对象,命名为uni-push,会创建一个uni-push目录

image.png

  1. 右击uni-push目录,点击 管理公共模块或扩展库依赖,选择uni-cloud-push

image.png

  1. 右击database目录,新建DB Schema,创建这三张表:opendb-tempdata,opendb-device,uni-id-device,也就是json文件,直接输入并选择相应的模板。
  • 修改index.js
'use strict';
const uniPush = uniCloud.getPushManager({appId:"__UNI__XXXX"}) //注意这里需要传入你的应用appId
exports.main = async (event, context) => {
console.log('event ===> ', event)
console.log('context ===> ', context)
// 所有要传的参数,都在业务服务器调用此接口时传入
const data = JSON.parse(event.body || '{}')
console.log('params ===> ', data)
return await uniPush.sendMessage(data)
};

  • package.json
{
"name": "uni-push",
"dependencies": {},
"main": "index.js",
"extensions": {
"uni-cloud-push": {}
}
}
  1. 右击uni-push目录,点击上传部署
  2. 云函数url化

    登录云函数控制台,进入云函数详情

image.png 8. postman测试一下接口

image.png

没问题的话,客户端将会打印“console.log("收到推送消息:", xxx)”,这一步最好是使用真机,运行到App基座,使用自定义调试基座运行,会在HBuilderX控制台打印。

离线推送

APP离线时,客户端收到通知会自动在通知栏创建消息,实现离线推送需要配置厂商参数。

苹果需要专用的推送证书,创建证书参考链接

image.png 安卓需要在各厂商开发者后台获取参数,参考链接

参数配置好了以后,再次在postman测试

注意 安卓需要退出app后,在任务管理器彻底清除进程,才会走离线推送

解决离线推送没有声音

这个是因为各安卓厂商为了避免开发者滥用推送进行的限制,因此需要设置离线推送渠道,查看文档

调接口时需要传一个channel参数

image.png

实现离线推送自定义铃声

这个功能只有华为和小米支持

也需要设置channel参数,并使用原生插件,插件地址

注意 使用了原生插件,一定要重新进行一次云打包

  • 华为,申请了自分类权益即可
  • 小米,在申请渠道时,选择系统铃声,url为android.resource://安卓包名/raw/铃声文件名(不要带后缀)


作者:xintianyou
来源:juejin.cn/post/7267417057451573304
收起阅读 »

你的 Flutter 项目异常太多是因为代码没有这样写

以前在团队里 Review 过无数代码,也算是阅码无数的人了,解决过的线上异常也数不胜数,故对如何写出健状性的代码有一些微小的见解。刚好最近闲来得空(其实是拖延症晚期)把之前躺在备忘录里的一些小记整理了一下,希望能对你有一些启发。 Uri 对象的使用 在 Da...
继续阅读 »

以前在团队里 Review 过无数代码,也算是阅码无数的人了,解决过的线上异常也数不胜数,故对如何写出健状性的代码有一些微小的见解。刚好最近闲来得空(其实是拖延症晚期)把之前躺在备忘录里的一些小记整理了一下,希望能对你有一些启发。


Uri 对象的使用


在 Dart 语言中 Uri 类用于表示 URIs(网络地址、文件地址或者路由),它内部能自动处理地址的模式与的百分号编解码。在实际开发过程我们经常直接使用字符串进行拼接 URIs,然而这种方式会给地址的高级处理带来不便甚至隐式异常。


/// 假设当前代码为一个内部系统,[outsideInput] 变量是外部系统传入内部的字符串变量
/// 你无法限定 [outsideInput] 的内容,如果变量包含非法字符(如中文),整个地址非法
final someAddress = 'https://www.special.com/?a=${outsideInput}';
/// 为了保持 URI 的完整性你可能会这样做
final someAddress = 'https://www.special.com/?a=${Uri.decodeFull(outsideInput)}';
/// 如有多个外部输入变量你又需要这样做
final someAddress = 'https://www.special.com/?a=${Uri.encodeFull(outsideInput)}&b=${Uri.encodeFull(outsideInput1)}&c=${Uri.encodeFull(outsideInput2)}';

直接使用字符串来拼接 URI 地址会带来非常多的限制,你需要关注地址中拼接的每个部分的合法性,并且在处理复杂逻辑时需要更冗长为的处理。


/// 如果我们需要在另一个系统中对 [someAddress] 地址的参数按条件进行添加
if (conditionA) {
someAddress = 'https://www.special.com/?a=${Uri.encodeFull(outsideInput)}';
} else if (conditionB) {
someAddress = 'https://www.special.com/?a=${Uri.encodeFull(outsideInput)}&b=${otherVariable}';
} else {
someAddress = 'https://www.special.com';
}

如果使用 Uri 可以简化绝大多数对 URIs 的处理,同时限定类型对外部有更明确的确定性,因此针对 URIs 需要做如下约定:


在任何系统中都不应直接拼接 URIs 字符串,应当构造 URI 对象作为参数或返回值。


/// 生成 Uri 对象
final someAddress = Uri(path: 'some/sys/path/loc', queryParameters: {
'a': '${outsideInput}', // 非法参数将自动百分号编码
'b': '${outsideInput1}', // 不用对每个参数单独进行编码
if (conditionA) 'c': '${outsideInput2}', // 条件参数更为简洁
});

类型转换


Dart 中可以使用 is 进行类型判断,as 进行类型转换。 同时,使用 is 进行类型判断成功后会进行隐性的类型转换。示例如下:


class Animal {
void eat(String food) {
print('eat $food');
}
}

class Bird extends Animal {
void fly() {
print('flying');
}
}

void main() {
Object animal = Bird();

if (animal is Bird) {
animal.fly(); // 隐式类型转换
}

(animal as Animal).eat('meat'); // 强制类型转换一旦失败就会抛异常
}

由于隐式的类型转换存在,is 可以充当 as 的功能,同时 as 进行类型失败会抛出异常。


所以日常开发中建议使用 is 而不是 as 来进行类型转换。 is 运算符允许更安全地进行类型检查,如果转换失败,也不会抛出异常。


void main() {
dynamic animal = Bird();

if (animal is Bird) {
animal.fly();
} else {
print('转换失败');
}
}

List 使用


collection package 的使用


List 作为 Dart 中的基础对象使用广范,由于其本身的特殊性,如使用不当极易导致异常,从而影响业务逻辑。典型示例如下:


 List<int> list = [];
// 当 List 为空时访问其 first 会抛异常
list.first
// 同理访问 last 也会抛异常
list.last
// 查找对象时没有提供 orElse 也会抛异常
list.firstWhere((t) => t > 0);

// List 对象其它会抛异常的访问还有
list.single
list.lastWhere((t) => t > 0)
list.singleWhere((t) => t > 0)

所以如果没有前置判断条件,所有对 List 的访问均需替换为 collection 里对应的方法。


import 'package:collection/collection.dart';

List<int> list = [];
list.firstOrNull;
list.lastOrNull;
list.firstWhereOrNull((t) => t > 0);
list.singleOrNull;
list.lastWhereOrNull((t) => t > 0);
list.singleWhereOrNull((t) => t > 0);

取元素越界


在 Dart 开发时,碰到数组越界或者访问数组中不存在的元素情况时,会导致运行时错误,如:


List<int> numbers = [0, 1, 2];
print(numbers[3]); // RangeError (index): Index out of range: index should be less than 3: 3

你可以使用使用 try-catch 来捕获异常,但由于数组取值这样的基础操作往往遍布在项目的各个角落,try-catch在这样的情况下使用起来会比较繁琐,而且并不是所有的取值都会导致异常,所以往往越界的问题只有真正出现了才发现。


好在,我们可以封装一个 extension 来简化数组越界的问题:


extension SafeGetList<T> on List<T> {
T? tryGet(int index) =>
index < 0 || index >= this.length ? null : this[index];
}

使用时:


final list = <int>[];

final single = list.tryGet(0) ?? 0;

由于 tryGet 返回值类型为可空(T?) ,外部接收时需要进行空判断或者赋默认值,这相当于强迫开发者去思考值不存在的情况,如此减少了异常发生的可能,同时在业务上也更加严谨。


当然还有另一种方案,可以继承一个 ListMixin 的自定义类:SafeList,其代码如下:


class SafeList<T> extends ListMixin<T> {

final List<T?> _rawList;

final T defaultValue;

final T absentValue;

SafeList({
required this.defaultValue,
required this.absentValue,
List<T>? initList,
}) : _rawList = List.from(initList ?? []);

@override
T operator [](int index) => index < _rawList.length ? _rawList[index] ?? defaultValue : absentValue;

@override
void operator []=(int index, T value) {
if (_rawList.length == index) {
_rawList.add(value);
} else {
_rawList[index] = value;
}
}

@override
int get length => _rawList.length;

@override
T get first => _rawList.isNotEmpty ? _rawList.first ?? defaultValue : absentValue;

@override
T get last => _rawList.isNotEmpty ? _rawList.last ?? defaultValue : absentValue;

@override
set length(int newValue) {
_rawList.length = newValue;
}
}

使用:


final list = SafeList(defaultValue: 0, absentValue: 100, initList: [1,2,3]);

print(list[0]); // 正常输出: 1
print(list[3]); // 越界,输出缺省值: 100
list.length = 101;
print(list[100]); // 改变数组长度了,输出默认值: 0

以上两种方案均可以解决越界的问题,第一个方案更简洁,第二个方案略复杂且侵略性也更强但好处是可以统一默认值、缺省值,具体使用哪种取决于你的场景。


ChangeNotifier 使用


ChangeNotifier 的属性访问或方法调用


ChangeNotifier 及其子类在 dispose 之后将不可使用,dispose 后访问其属性(hasListener)或方法(notifyListeners)时均不合法,在 Debug 模式下会触发断言异常;


// ChangeNotifier 源码
bool get hasListeners {
// 访问属性时会进行断言检查
assert(ChangeNotifier.debugAssertNotDisposed(this));
return _count > 0;
}

void dispose() {
assert(ChangeNotifier.debugAssertNotDisposed(this));
assert(() {
// dispose 后会设置此标志位
_debugDisposed = true;
return true;
}());
_listeners = _emptyListeners;
_count = 0;
}

static bool debugAssertNotDisposed(ChangeNotifier notifier) {
assert(() {
if (notifier._debugDisposed) { // 断言检查是否 dispose
throw FlutterError(
'A ${notifier.runtimeType} was used after being disposed.\n'
'Once you have called dispose() on a ${notifier.runtimeType}, it '
'can no longer be used.',
);
}
return true;
}());
return true;
}


dispose 后访问属性或调用方法通常出现在异步调用的场景下,由其是在网络请求之后刷新界面。典型场景如下:


class PageNotifier extends ChangeNotifier { 
dynamic pageData;

Future<voud> beginRefresh() async {
final response = await API.getPageContent();
if (!response.success) return;
pageData = response.data;
// 接口返回之后此实例可能被 dispose,从而导致异常
notifyListeners();
}
}

为使代码逻辑更加严谨,增强整个代码的健状性:


ChangeNotifier 在有异步的场景情况下,所有对 ChangeNotifier 属性及方法的访问都需要进行是否 dispose 的判断。


你可能会想到加一个 hasListeners 判断:


class PageNotifier extends ChangeNotifier { 
dynamic pageData;

Future<voud> beginRefresh() async {
final response = await API.getPageContent();
if (!response.success) return;
pageData = response.data;
// Debug 模式下 hasListeners 依然可能会抛异常
if (hasListeners) notifyListeners();
}
}

如上所述 hasListeners 内部仍然会进行是否 dispose 的断言判断,所以 hasListeners 仍然不安全。


因此正确的做法是:


// 统一定义如下 mixin
mixin Disposed on ChangeNotifier {
bool _disposed = false;

bool get hasListeners {
if (_disposed) return false;
return super.hasListeners;
}

@override
void notifyListeners() {
if (_disposed) return;
super.notifyListeners();
}

@override
void dispose() {
_disposed = true;
super.dispose();
}
}

// 在必要的 ChangeNotifier 子类混入 Disposed
class PageNotifier extends ChangeNotifier with Disposed {

Future<voud> beginRefresh() async {
final response = await API.getPageContent();
if (!response.success) return;
pageData = response.data;
// 异步调用不会异常
notifyListeners();
}

}

ChangeNotifier 禁止实例复用


ChangeNotifier 在各种状态管理模式中一般都用于承载业务逻辑,初入 Flutter 的开发者会受原生开发的思维模式影响可能会将 ChangeNotifier 实例进行跨组件复用。典型的使用场景是购物车,购物车有加/减商品、数量管理、折扣管理、优惠计算等复杂逻辑,将 ChangeNotifier 单个实例复用甚至单例化能提高编码效率。


但单个 ChangeNotifier 实例在多个独立的组件或页面中使用会造成潜在的问题:复用的实例一旦在某个组件中被意外 dispose 之后就无法使用,从而影响其它组件展示逻辑并且这种影响是全局的。


@override
void initState() {
super.initState();
// 添加监听
ShoppingCart.instance.addListener(_update);
}

@override
void dispose() {
// 正确移除监听
ShoppingCart.instance.removeListener(_update);
// 如果哪个实习生不小心在组件中这样移除监听,将产生致命影响
// ShoppingCart.instance.dispose();
super.dispose();
}


因此在 Flutter 开发中应禁止 ChangeNotifier 实例对外跨组件直接复用,如需跨组件复用应借助providerget_it 等框架将 ChangeNotifer 子类实例对象置于顶层;


void main() {
runApp(
MultiProvider(
providers: [
Provider<Something>.value(ShoppingCart.instance),
],
child: const MyApp(),
)
);
}


如果你非得要 「单例化」 自定义 ChangeNotifier 子类实例,记得一定要重新 dispose 函数。


Controller 使用


在 Flutter 中大多数 Controller 都直接或间接继承自 ChangeNotifier。为使代码逻辑更加严谨,增强整个代码的健状性,建议:


所有 Controller 需要显式调用 dispose 方法,所有自定义 Controller 需要重写或者添加 dispose 方法。


// ScrollController 源码
class ScrollController extends ChangeNotifier {
//...
}

// 自定义 Controller 需要添加 dispose 方法
class MyScrollController {
ScrollController scroll = ScrollController();

// 添加 dispose 方法
void dispose() {
scroll.dispose();
}
}

ChangeNotifierProvider 使用


ChangeNotifierProvider 有两个构造方法:



  • ChangeNotifierProvider.value({value:})

  • ChangeNotifierProvider({builder:})


使用 value 构造方法时需要注意:value 传入的是一个已构造好的 ChangeNotifier 子类实例,此实例不由 Provider 内构建,Provider 不负责此实例的 dispose



虽然这个差异在 Provider 文档中有重点说明,但仍然有不少开发人员在写代码的过程中混用,故在此再次强调



因此开发人员在使用 ChangeNotifierProvider.value 时为使代码逻辑更加严谨,增强整个代码的健状性,培养良好的开发习惯开发人员需践行以下规范:


使用 ChangeNotifierProvider.value 构造方法时传入的实例一定是一个已构建好的实例,你有义务自行处理此实例的 dispose。使用 ChangeNotifierProvider(builder:) 构造方法时你不应该传入一个已构建好的实例,这会导致生命周期混乱,从而导致异常。


你需要这样做



MyChangeNotifier variable;

void initState() {
super.initState();
variable = MyChangeNotifier(); // 提前构建实例
}

void build(BuildContext context) {
return ChangeNotifierProvider.value(
value: variable, // 已构建好的实例
child: ...
);
}

void dispose() {
super.dispose();
variable.dispose(); // 主动 dispose
}

你不能这样做



MyChangeNotifier variable;

void initState() {
super.initState();
variable = MyChangeNotifier();
}

void build(BuildContext context) {
// create 对象的生命周期只存在于 Provider 树下,此处应不直接使用此实例
return ChangeNotifierProvider(
create: (_) => variable,
child: ...
);
}


避免资源释放遗忘


在 Flutter 中有很多需要主动进行资源释放的类型,包含但不限于:TimerStreamSubscriptionScrollControllerTextEditingController等,另外很多第三方库存在需要进行资源释放的类型。


如此多的资源释放类型管理起来是非常麻烦的,一旦忘记某个类型的释放很会造成整个页面的内存泄漏。而资源的创建一般都位于 initState 内,资源释放都位于 dispose 内。


为了减小忘记资源释放的可能性,dispose 应为 State 内的第一个函数并尽可能的将 initsate 紧跟在 dispose


这样在代码 Review 时可以从视觉上一眼看出来资源释放是否被遗忘。


Bad


final _controller = TextEditingController();
late Timer _timer;

void initState() {
super.initState();
_timer = Timer(...);
}

Widget build(BuildContext context) {
return SizedBox(
child: // 假设此处为简单的登录界面,也将是一串很长的构建代码
);
}

void didChangeDependencies() {
super.didChangeDependencies();
// 又是若干行
}

// dispose 函数在 State 末尾,与 initState 大概率会超过一屏的距离
// 致使 dispose 需要释放的资源与创建的资源脱节
// 无法直观看出是否漏写释放函数
void dispose() {
_timer.cancell();
super.dispose();
}

Good


final _controller = TextEditingController();
late Timer _timer;

// 属性后第一个函数应为 dispose
void dispose() {
_controller.dispose();
_timer.cancell();
super.dispose();
}
// 中间不要插入其它函数,紧跟着写 initState
void initState() {
super.initState();
_timer = Timer(...);
}

上面推荐的写法也可以用在自定义的 ChangeNotifer 子类中,将 dispose 函数紧在构造函数后,有利于释放遗漏检查。


由于创建资源与释放资源在不同的函数内,因此存在一种情况:为了释放资源不得不在 State 内加一个变量以便于在 dipose 函数中引用并释放,即便此资源仅在局部使用。


典型场景如下:



late CancelToken _token;

Future<void> _refreshPage() async {
// _token 只在页面刷新的函数中使用,却不得不加一个变量来引用它
_token = CancelToken();

Dio dio = Dio();
Response response = await dio.get(url, cancelToken: _token);
int code = response.statusCode;
// ...
}

void dispose() {
super.dispose();
_token.cancel();
}

这样的场景在一个页面内可能有多处,相同的处理方式使用起来就略显麻烦了,也容易导致遗忘。因此推荐如下写法:


// 创建下面的 Mixin
mixin AutomaticDisposeMixin<T extends StatefulWidget> on State<T> {
Set<VoidCallback> _disposeSet = Set<VoidCallback>();

void autoDispose(VoidCallback callabck) {
_disposeSet.add(callabck);
}

void dispose() {
_disposeSet.forEach((f) => f());
_disposeSet.removeAll();
super.dispose();
}
}

class _PageState extends State<Page> with AutomaticDisposeMixin {
Future<void> _refreshPage() async {
final token = CancelToken();
// 添加到自动释放队列
autoDispose(() => token.cancel());
Dio dio = Dio();
Response response = await dio.get(url, cancelToken: token);
int code = response.statusCode;
// ...
}
}

当然也这种用法不限于局部变量,同样也可以在 initState 内进行资源声明的同时进行资源释放,这种写法相对来讲更加直观,更不易遗漏资源释放。



final _controller = TextEditingController();

void initState() {
super.initState();
_timer = Timer(...);
autoDispose(() => _timer.cancel());
autoDispose(() => _controller.dispose());
}


StatefulWidget 使用


State 中存在异步刷新


在开发过程中简单的页面或组件通常直接使用 StatefulWidget 进行构建,并在 State 中实现状态逻辑。因此 State 不可避免可能会存在异步刷新的场景。但异步结束时当前 Widget 可能已经从当前渲染树移除,直接刷新当前 Widget 可能导致异常。典型示例如下:


class SomPageState extends State<SomePageWidget> {

PageData _data;

Future<void> _refreshPage() async {
// 异步可能是延时、接口、文件读取、平台状态获取等
final response = await API.getPageDetaile();
if (!response.success) return;
// 直接界面刷新页面可能会导致异常,当前 Widget 可能已从渲染树移除
setState((){
_data = response.data;
});
}
}

为使代码逻辑更加严谨,增强整个代码的健壮性,培养良好的开发习惯,建议:


State 里异步刷新 UI 时需要进行 mounted 判断,确认当前 Widget 在渲染树中时才需要进行界面刷新否则应忽略。


Future<void> _refreshPage() async {
// 异步可能是接口、文件读取、状态获取等
final response = await API.getPageDetaile();
if (!response.success) return;
// 当前 Widget 存在于渲染树中才刷新
if (!mounted) return;
setState((){
_data = response.data;
});
}

上面的 mounted 判断可能会存在于所有 State 中又或者一个 State 里有多个异步 setState 调用,每个调用都去判断过于繁锁,因此更推荐如下写法:


// 统一定义如下 mixin
mixin Stateable<T extends StatefulWidget> on State<T> {
@override
void setState(VoidCallback fn) {
if (!mounted) return;
super.setState(fn);
}
}

// 在存在异步刷新的 State 中 with 如上 mixin
class SomPageState extends State<SomePageWidget> with Stateable {
//...
}

作者:码不理
来源:juejin.cn/post/7375882178012577802
收起阅读 »

使用 uni-app 开发 APP 并上架 IOS 全过程

教你用 uni-app 开发 APP 上架 IOS 和 Android 介绍 本文记录了我使用uni-app开发构建并发布跨平台移动应用的全过程,旨在帮助新手开发者掌握如何使用uni-app进行APP开发并最终成功上架。通过详细讲解从注册开发者账号、项目创建、...
继续阅读 »

教你用 uni-app 开发 APP 上架 IOS 和 Android


介绍


本文记录了我使用uni-app开发构建并发布跨平台移动应用的全过程,旨在帮助新手开发者掌握如何使用uni-app进行APP开发并最终成功上架。通过详细讲解从注册开发者账号、项目创建、打包发布到应用商店配置的每一步骤,希望我的经验分享能为您提供实用的指导和帮助,让您在开发之旅中少走弯路,顺利实现自己的应用开发目标。


环境配置


IOS 环境配置


注册开发者账号 


如果没有开发者账号需要注册苹果开发者账号,并且加入 “iOS Developer Program”,如果是公司项目那么可以将个人账号邀请到公司的项目中。


获取开发证书和配置文件



登录Apple Developer找到创建证书入口



申请证书的流程可以参考Dcloud官方的教程,申请ios证书教程


开发证书和发布证书都申请好应该是这个样子



创建App ID


创建一个App ID。App ID是iOS应用的唯一标识符,稍后你会在uni-app项目的配置文件中使用它。



配置测试机


第一步打开开发者后台点击Devices



第二步填写UDID



第三步重新生成开发证书并且勾选新增的测试机,建议一次性将所有需要测试的手机加入将来就不用一遍遍重复生成证书了




Android 环境配置


生成证书


Android平台签名证书(.keystore)生成指南: ask.dcloud.net.cn/article/357…


uni-app 项目构建配置


基础配置



版本号versionCode 前八位代表年月日,后两位代表打包次数


APP 图标设置



APP启动界面配置



App模块配置


注意这个页面用到什么就配置什么不然会影响APP审核



App隐私弹框配置



注意根据工业和信息化部关于开展APP侵害用户权益专项整治要求应用启动运行时需弹出隐私政策协议,说明应用采集用户数据,这里将详细介绍如何配置弹出“隐私协议和政策”提示框



详细内容可参考Uni官方文档
注意!androidPrivacy.json不要添加注释,会影响隐私政策提示框的显示!!!


在app启动界面配置勾选后会在项目中自动添加androidPrivacy.json文件,可以双击打开自定义配置以下内容:


{
"version" : "1",
"prompt" : "template",
"title" : "服务协议和隐私政策",
"message" : "  请你务必审慎阅读、充分理解“服务协议”和“隐私政策”各条款,包括但不限于:为了更好的向你提供服务,我们需要收集你的设备标识、操作日志等信息用于分析、优化应用性能。<br/>  你可阅读<a href="https://xxx.xxx.com/userPolicy.html">《服务协议》</a>和<a href="https://xxxx.xxxx.com/privacyPolicy.html">《隐私政策》</a>了解详细信息。如果你同意,请点击下面按钮开始接受我们的服务。",
"buttonAccept" : "同意并接受",
"buttonRefuse" : "暂不同意",
"hrefLoader" : "system|default",
"backToExit" : "false",
"second" : {
"title" : "确认提示",
"message" : "  进入应用前,你需先同意<a href="https://xxx.xxxx.com/userPolicy.html">《服务协议》</a>和<a href="https://xxx.xxxx.com/userPolicy.html">《隐私政策》</a>,否则将退出应用。",
"buttonAccept" : "同意并继续",
"buttonRefuse" : "退出应用"
},
"disagreeMode" : {
"loadNativePlugins" : false,
"showAlways" : false
},
"styles" : {
"backgroundColor" : "#fff",
"borderRadius" : "5px",
"title" : {
"color" : "#fff"
},
"buttonAccept" : {
"color" : "#22B07D"
},
"buttonRefuse" : {
"color" : "#22B07D"
},
"buttonVisitor" : {
"color" : "#22B07D"
}
}
}

我的隐私协议页面是通过vite打包生成的多入口页面进行访问,因为只能填一个地址所以直接使用生产环境的例如:xxx.xxxx.com/userPolicy.…


构建打包


使用HBuilderX进行云打包


IOS打包


构建测试包


第一步 点击发行->原生app云打包



第二步配置打包变量



运行测试包

打开HbuildX->点击运行->运行到IOS App基座



选择设备->使用自定义基座运行



构建生产包


和构建测试包基本差不多,需要变更的就是ios证书的profile文件和密钥证书



构建成功后的包在dist目录下release文件夹中



上传生产包


上传IOS安装包的方式有很多我们选择通过transporter软件上传,下载transporter并上传安装包



确认无误后点击交付,点击交付后刷新后台,一般是5分钟左右就可以出现新的包了。



App store connect 配置


上传截屏

只要传6.5和5.5两种尺寸的就可,注意打包的时候千万不能勾选支持ipad选项,不然这里就会要求上传ipad截屏



填写app信息


配置发布方式

自动发布会在审核完成后直接发布,建议选手动发布



配置销售范围


配置隐私政策


配置完之后IOS就可以提交审核了,不管审核成功还是失败Apple都会发一封邮件通知你审核结果


安卓打包


构建测试包


a3_mosaic_mosaic.png


构建的包在dist/debug目录下



运行测试包

如果需要运行的话,点击运行 -> 运行到Android App底座




构建生产包



构建后的包在dist目录下release文件夹中



构建好安卓包之后就可以在国内的各大手机厂商的应用商店上架了,由于安卓市场平台五花八门就不给大家一一列举了。


参考链接:



结语


本文介绍了使用uni-app开发并发布跨平台移动应用的完整流程,包括注册开发者账号、项目创建、打包发布以及应用商店配置,帮助开发者高效地将应用上架到iOS和Android平台。感谢您的阅读,希望本文能对您有所帮助。


作者:饼饼饼
来源:juejin.cn/post/7379958888909029395
收起阅读 »

从劝退 flutter_screenutil 聊到不同尺寸 UI 适配的最佳实践

先说优点 💡 先说优点叠个甲,毕竟库本身没有太大问题,往往都是使用的人有问题。 由于是基于设计稿进行屏幕适配的框架,在处理不同尺寸的屏幕时,都可以使用相同的 尺寸数值+单位 ,实现对设计稿等比例的适配,同时保真程度一般很高。 在有设计稿的情况下,只使用 C...
继续阅读 »

先说优点



💡 先说优点叠个甲,毕竟库本身没有太大问题,往往都是使用的人有问题。



由于是基于设计稿进行屏幕适配的框架,在处理不同尺寸的屏幕时,都可以使用相同的 尺寸数值+单位 ,实现对设计稿等比例的适配,同时保真程度一般很高。


在有设计稿的情况下,只使用 Container + GestureDetector 都可以做到快速的开发,可谓是十分的无脑梭哈。


在:只考虑移动端、可以接受使用大屏幕手机看小屏幕 ui、不考虑大字体的模式、被强烈要求还原设计稿、急着开发。的情况下,还是挺好用的。


为什么劝退?



来到我劝退师最喜欢的一个问题,为什么劝退。如果做得不好,瞎搞乱搞,那就是我劝退的对象。



在亲身使用了两个项目并结合群里的各种疑惑,我遇到常见的有如下问题:


如何实现对平板甚至是桌面设备的适配?


由于基于设计稿尺寸,平板、桌面等设备的适配基本上是没法做的,要做也是费力不讨好的事。


千万不要想着说,我通过屏幕宽度断点来使用不同的设计稿,当用户拉动边框来修改页面的宽度时,体验感是很崩溃的。而且三套设计稿要写三遍不同的代码,就更不提了。(这里说三遍代码的原因是,计算 .w .h 的布局,数据会跟随设计稿变化)


如何适配大字体无障碍?


因为大字体缩放在满屏的 .w .h 下,也就是写死了尺寸的情况下,字体由于随系统字体放大,布局是绝对会溢出的。很多项目开发到最后上线才意识到自己有大字体无障碍的用户,甚至某些博客上,使用了一句:


MediaQuery.of(context).copyWith(textScaleFactor: 1.0),

来处理掉自己的用户,强制所有屏幕字体不可缩放。一时的勉强敷衍过去,最后只能等项目慢慢腐烂。


为什么在 1.w 的情况下会很糊?同样是 16.sp 为什么肉眼可见的不一样大?


库的原理很简单,提供了一堆的 api 相对于设计图的宽高去做等比例计算,所以必然存在一个问题,计算结果是浮点数。可是?浮点数有什么问题吗?


梳理一下原理:已知屏幕设计图宽度 sdw 、组件设计图宽度 dw ,根据屏幕实际宽度 sw ,去计算得出组件实际宽度 w


w = sw / sdw * dw

可是设计图的屏幕宽度 sdw 作为分母时,并不能保证总是可以被表示为有限小数。举个例子:库的文档中给的示例是 const Size(360, 690), 的尺寸,如果我需要一个 100.w 会得到多少?在屏幕宽度为 420 的情况下,得到组件宽度应该为 116.6666... 的无限小数


这会导致最终在栅格化时会面临消除小数点像素的锯齿问题。一旦有像素点的偏差,就会导致边缘模糊。


字体对尺寸大小更为敏感,一些非矢量的字体甚至只有几个档位的大小,当使用 14.5、15、15.5 的字体大小时,可能会得到一样的视觉大小,再加上 .sp 去计算一道,误差更是放大。



具体是否会发生在栅格化阶段,哪怕文章有误也无所谓,小数点像素在物理意义上就是不存在的,总是会面临锯齿平滑的处理,导致无法像素级还原 UI。



为什么部分屏幕下会溢出?


我们知道了有小数点问题,那么不得不说起计算机编程常见的一个不等式:


0.1 + 0.2 != 0.3

由于底层表示浮点数本身就有的精度问题,现在让 Flutter 去做这个加法,一样会溢出。考虑以下代码:


    Row(
children: [
SizedBox(width: 60.w),
SizedBox(width: 100.w),
SizedBox(width: 200.w),
],
);

在一个总共宽度 360.w 的设计图上,可能出现了溢出,如果不去使用多个屏幕来调试,根本不会觉得异常,毕竟设计图是这样做的,我也是这样写的,怎么可能有错呢?


然而恰恰是库本身的小数问题,加上编程届常见的底层浮点数精度问题,导致边缘溢出一点点像素。


我使用了 screenutil 为什么和真实的单位 1px 1rem 1dp 的大小不同呢?


哪怕是 .sp 都是基于设计图等比例缩放的,使用 screenutil 就从来不存在真实大小,计算的结果都是基于设计稿的相对大小。就连 .w.h 都没法保证比例相同,导致所有布局优先使用 .w 来编写代码的库,还想保证和真实尺寸相等?


为什么需要响应式 UI?


说个题外话:在面试淘菜菜的时候真的会有点崩不住,他们问如何做好不同屏幕的适配,我说首先这是 UI 出图的问题,如果 UI 出的图是响应式的,那没问题,照着写,闭着眼都能适配。


但是如果设计图不是响应式的,使用 flutter_screenutil 可以做到和设计图高保真等比还原,但是如果做多平台就需要 UI 根据屏幕断点出不同平台的设计图。


面试官立即就打断我说他们的 UI 只会出一份图。我当场就沉默了,然后呢?也不说话了?是因为只有移动端用户,或者说贵公司 UI 太菜了,还是说都太菜了。菜就给我往下学 ⏬


首先 UI 的响应式设计是 UI 的责任


抛开国情不谈,因为国内的 UI 能做到设计的同时,UI 还是响应式的,这样的 UI 设计师很少很少,他们能把主题规范好,约定好,已经是不得了的了。


但即使如此,响应式 UI 设计也还是应该归于 UI 设计中,在设计图中去根据不同的尺寸,拖动验证不同的布局效果是很容易的。在不同的尺寸下,应该怎么调整元素个数,应该如何去布局元素,只有 UI 使用响应式的写法去实现了,UI 和开发之间的无效交流才会减少。


响应式的 UI 可以避免精度问题


早在 19 年我就有幸翻阅了一本 iOS 的 UI 设计规范,当时有个特别的点特别印象深刻:尺寸大小应该为 2 的整数次幂,或者 4 的倍数。因为这样做,在显示和计算上会较为友好。



💡 这其实是有点历史原因的,之前的 UI 在栅格化上做得并不是很好,锯齿化严重也是常态,所以使用可以被 2 整除的尺寸,一方面使用起来只有几个档位,方便调整;另一方面这样的尺寸可以在像素的栅格化上把小数除尽。



举个例子,在屏幕中间显示一个 300 宽度的卡片,和边距 16 的卡片,哪一个更响应式,无疑是后者,前者由于需要计算 300 相对与设计稿屏幕的宽度,后者只需要准确的执行 16 的边距就好,中间的卡片宽度随屏幕的宽度自动变化。


同样的例子,带有 Expanded 布局的 Row 组件,相比直接给定每个子组件尺寸导致精度问题的布局,更能适配不同的屏幕。因为 Row 会先放置固定大小的组件,剩余空间由 Expanded 去计算好传给子组件,原理和 Web 开发中的 flex 布局一样。


响应式布局是通用的规范


如果有 Web 开发经验的,应该会知道 Web 的屏幕是最多变的,但是设计起来也可以很规范,常见的 bootstrap 框架就提到了断点这个观点,指出了当我们去做 UI 适配的时候,需要根据不同的屏幕大小去做适配。同时 flex 布局也是 Web 布局中常用的响应式布局手段。


在设计工具中,响应式 UI 也没有那么遥远,去下载一份 Material Design 的 demo,对里面的组件自由的拉伸缩放,再对比一下自己通过输入尺寸大小拼凑在一起的 UI,找找参数里面哪里有差异。


怎么做响应式 UI


这里直接放一个谷歌大会的演讲,我相信下面的总结其实都可以不用看了,毕竟本实验室没有什么可补充的,但是我们还是通过从外到内、从整体到局部的顺序来梳理一下如何去做一个响应式的 UI,从而彻底告别使用 flutter_screenutil。


http://www.youtube.com/watch?v=LeK…


SafeArea


一个简单的组件,可以确保内部的 UI 不会因为愚蠢的设备圆角、前置挖孔摄像头、折叠屏链接脚、全面屏边框等原因而被意外的裁剪,将重要的内容,显示在“安全区”中。


屏幕断点


让 UI 根据不同的尺寸的窗口变化而变化,首先就要使用 MediaQuery.sizeOf(context);LayoutBuilder() 来实现对窗口的宽度的获取,然后通过不同的屏幕断点,去构建不同情况下的 UI。


其中 LayoutBuilder 还能获取当前约束下的宽度,以实现页面中子区域的布局,比如 Drawer 的宽度,对话框的宽度,导航的宽度。


这里举了个例子,使用媒体查询获得窗口宽度之后,展示不同的 Dialog



写出如此优雅的断点代码只需要三步:



  • 抽象:找到全屏对话框和普通对话框中共同的属性,并将功能页面提取出来。

  • 测量:思考应该使用窗口级别的宽度(MediaQuery),还是某个约束下的宽度(LayoutBuilder)。

  • 分支:编写如上图所示的带有断点逻辑的代码。



GridView


熟悉了移动端的 ListView 布局之后,切换到 GridView 布局并适配到平板、桌面端,是一件十分自然的事,只需要根据情况使用不同的 gridDelegate 属性来设置布局方式,就能简单的适配。


这里一般使用 SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: ) 方法来适配,传入一个期望的最大宽度,使其在任何屏幕上看到的子组件都自然清晰,GridView 会根据宽度计算出合适的一行里合适的列数。


Flex 布局,但是 Flutter 版


前面说过了尽量不要去写固定尺寸的几个元素加起来等于屏幕宽度,没有那么巧合的事情。在 Row/Column 中,善用 Expanded 去展开子组件占用剩余空间,善用 Flexible 去缩紧子组件,最后善用 Spacer 去占用空白,结合 MainAxisAlignment 的属性,你会发现布局是那样的自然。


只有部分组件是固定尺寸的


例如 Icon 一般默认 24,AppBar 和 BottomNavigationBar 高度为 56,这些是写在 MD 设计中的固定尺寸,但是一般不去修改。图片或许是固定尺寸的,但是一般也使用 AspectRatio 来固定宽高比。


我曾经也说过一个普遍的公理,因为有太多初学者容易因为这个问题而出错了。



当你去动态计算宽高的时候,可能是布局思路有问题了。



在大多数情况下,你的布局都不应该计算宽高,交给响应式布局,让组件通过自己的能力去得出自己的位置、约束、尺寸。


举一个遇到过的群友问题,他使用了 stack 布局包裹了应用栏和一个滚动布局,由于SliverAppBar 拉伸后的高度会变化,他想去动态的计算下方的滚动布局的组件起始位置。这个问题就连描述出来都是不可思议的,然后他问我,我应该如何去获取这个 AppBar 的高度,因为我想计算下方组件的高度。(原问题记不清了,但是这样的需求是不成立的)


最后,多看文档


最后补上关于 MD3 设计中,关于布局的文档,仔细学习:


Layout – Material Design 3


最后的最后,响应式布局其实是一个很宽的话题,这里没法三言两语说完,只能先暂时在某些领域劝退使用这个库。任何觉得可能布局困难的需求,都可以发到评论区讨论,下一篇文章我们将根据几个案例来谈谈具体的实践。


作者:优雅实践实验室
来源:juejin.cn/post/7386947074640298038
收起阅读 »

移动前端混合开发技术演进之路

本文是azuo和萌妹俩技术创作之旅的第15篇原创文章,内容创作@azuo😄,精神支持@大头萌妹😂 前言:本文主要探讨了移动混合开发( Hybrid APP) 开发的技术演进历程,将阐述了webview(H5)、React Native、小程序技术等在其中所扮...
继续阅读 »

本文是azuo和萌妹俩技术创作之旅的第15篇原创文章,内容创作@azuo😄,精神支持@大头萌妹😂



前言:本文主要探讨了移动混合开发( Hybrid APP) 开发的技术演进历程,将阐述了webview(H5)、React Native、小程序技术等在其中所扮演的关键角色及带来的变革。原生能力缺失、长时间白屏、用户操作响应不及时等web开发的问题是如何被解决的?


一、诞生背景


早期移动应用开发,由于机器硬件性能的方面影响,为了更好的用户体验(操作响应、流畅度和原生的能力),主要集中在原生应用开发上。


1.1 原生开发的缺点


原生应用开发周期和更新周期长,也逐渐在快速的迭代的互联网产品产生矛盾。


缺点:



  • 开发周期长:开发调试需要编译打包,动辄就需要几分钟甚至十几分钟,相比H5的亚秒级别的热更能力,是在太长了;

  • 更新周期长:正常的发版需要用户手动更新,无法做到H5这种发布即更新的效率。

  • 使用前需要安装;

  • 需要多端开发;(Android和iOS两端开发人力成本高)


1.2 web开发的缺点


原生应用的研发效率问题,也逐渐在快速的迭代的互联网产品产生矛盾。这时候,开发人就自然而然的想到web技术能力,快速开发和发版生效和跨平台能力。


web技术开发的H5界面,相比原生应用,缺点也很明显:



  1. 缺少系统的提供原生能力;

  2. 页面白屏时间长(原生基本可以做到1秒内,h5普遍在2秒以上);

  3. 用户操作响应不及时(动画卡、点击没有反应);


把Native开发和web开发的优缺点整合一下,就诞生了Hybrid App。Hybrid App技术从诞生到现在一直在解决这3个问题。


二、 提供原生能力


JSBridge技术是由 Hybrid 鼻祖框架phoneGap带到开发者的视野中,解决了第一个问题。它通过webview桥接(JSBridge)的方式层解决web开发能力不足的问题,让web页面可以用系统提供原生能力。


2.1 技术原理


Android原生开发提供了各种view控件(类比Dom元素:div、canvas、iframe),其中就用一个webview(类比iframe)。JSBridge 就像其名称中的『Bridge』的意义一样,是 Native 和非 Native 之间的桥梁,它的核心是 构建 Native 和非 Native 间消息通信的通道,而且是 双向通信的通道


image.png


双向通信的通道:



  • JS 向 Native 发送消息 : 调用相关功能、通知 Native 当前 JS 的相关状态等。

  • Native 向 JS 发送消息 : 回溯调用结果、消息推送、通知 JS 当前 Native 的状态等。


2.2 实现细节


Android可以通过webview将一些原生的Java方法注入到window上供Javascript调用。Javascript也可以直接在window上挂着全局对象给webview执行。


2.2.1  JavaScript 调用 Native


Android 可以采用下面的方式:


public class JSBridgeActivity extends Activity{ 
private WebView Wv;

@Override
publicvoidonCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
Wv = (WebView)findViewById(R.id.webView);
Wv.getSettings().setJavaScriptEnabled(true);
// 4.2 使用 @JavascriptInterface
Wv.addJavascriptInterface(new JavaScriptInterface(this), "nativeBridge");
// TODO 显示 WebView
}
}


public class JavaScriptInterface{
@JavascriptInterface
public void postMessage(String webMessage){
// Native 逻辑
}
}

前端调用方式:


// android会在window上注入nativeBridge对象
window.nativeBridge.postMessage(message);

native层除了上述方式被Javascript调用,还有可以拦截alert、confirm、console的日志输出、请求URL(伪协议)等方式,来的获取到Javascript调用native的意图。


2.2.2 Native 调用 JavaScript


相比于 JavaScript 调用 Native, Native 调用 JavaScript 较为简单, WebView 组件,都以子组件的形式存在于 View/Activity 中,直接调用相应的 API 即可(类比浏览器的window中的原生方法)。


// android 4.4之前
webView.loadUrl("javascript:"+javascriptString)

// android 4.4之后
webView.evaluateJavascript(
javaScriptString, // js表达式
new ValueCallback<String>() { // 表达式的值通过回调给native
@Override
public void onReceiveValue(String value){
// 鉴权拦截,一般估计页面域名白名单的方式
JSONObject json = new JSONObject(value)
switch(json.bridgeName){
// 处理
}

}
}
);

2.3  JSBridge 接口


JSBridge 技术是对JavaScript 和 Native之间的封装成JS SDK方便前端JS调用,主要功能有两个:调用 Native和 接收Native 被调。


(function () {
var id = 0,
callbacks = {};


window.JSBridge = {
// 调用 Native
invoke: function(bridgeName, callback, data) {
// 判断环境,获取不同的 nativeBridge
var thisId = id ++; // 获取唯一 id
callbacks[thisId] = callback; // 存储 Callback
nativeBridge.postMessage(JSON.stringify{
bridgeName: bridgeName,
data: data || {},
callbackId: thisId // 传到 Native 端
});
},
receiveMessage: function(msg) {
var bridgeName = msg.bridgeName,
data = msg.data || {},
callbackId = msg.callbackId; // Native 将 callbackId 原封不动传回
// 具体逻辑
// bridgeName 和 callbackId 不会同时存在
if (callbackId) {
if (callbacks[callbackId]) { // 找到相应句柄
callbacks[callbackId](msg.data); // 执行调用
}
} elseif (bridgeName) {


}
}
};
})();

JSBridge通过建立一个通信桥梁,使得JavaScript和原生代码可以相互调用,实现高效的数据传输和交互。这个过程是跨线程异步调用的,数据传输一般会经过两次序列化(还有提升的空间)


三、解决白屏


3.1 白屏产生的原因


原生APP安装后启动页面,在正常情况是不用再从网络获取资源,只需要请求后端接口获取数据就可以完成渲染了,网页不需要安装才,每次打开web页面都会从远程服务加载资源后,再请求后端数据后才能渲染。在用户等待资源加载过程和浏览器渲染未完成中,就会出现白屏。造成白屏的主要原因 -- 资源网络加载


首屏渲染SSR.drawio.png


3.2 离线包技术


离线包主要是识别特定url地址(通常是url参数=离线批次id,即:_bid=1221)后保存到用户手机硬盘。用户下次打开H5页面就可以不用走网络请求。离线包一包也会提供预下载能力,保证首次打开H5页面也可以获得收益。



离线包是完整的资源分发系统,需要一个完整的技术团队来建设和维护的。



3.2.1 离线包分发过程


分发流程中主要涉及4种角色:



  • 离线配置平台:配置平台可以提供离线配置能力、离线包管理(上传、禁用、清空)、离线包使用统计、离线包准入审核(自动(包大小限制)+人工(解决特殊case))

  • 离线配置服务: 配置服务主要提供服务层能力,实现离线配置服务,离线包更新服务,离线资源长传下载服务、离线资源使用统计服务

  • 离线SDK: 端内接入离线SDK,SDK主要与离线配置服务进行交互,完成离线资源的管理和接入配置能力

  • Native侧 : 实现拦截请求在特定的协议下接入离线资源


image.png


3.2.2 离线包加载过程


离线包的加载流程


image.png


3.2.3 拦截实现细节


实现WebViewClient: 继承WebViewClient类,并重写shouldInterceptRequest方法。这个方法会在WebView尝试加载一个URL时被调用,你可以在这里检查请求的URL,并决定是否拦截这个请求。


public class MyWebViewClient extends WebViewClient {  
private InputStream getOfflineResource(String url) {
// ... 你的实现代码 ...
return null; // 示例返回null,实际中应该返回InputStream
}

@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();

// 检查这个URL是否在你的离线包中
InputStream inputStream = getOfflineResource(url);
if (inputStream != null) {
// 如果在离线包中找到了资源,就返回一个WebResourceResponse对象
return new WebResourceResponse(
"text/html", // MIME类型,这里以HTML为例
"UTF-8", // 编码
inputStream
);
}
// 如果没有在离线包中找到资源,就返回null,让WebView按照默认的方式去加载这个URL
// 走网络请求获取
}
}

// 在你的Activity或Fragment中
WebView webView = findViewById(R.id.webview);
webView.setWebViewClient(new MyWebViewClient());

3.3 服务端渲染(SSR )


在3.1 白屏产生的原因,影响白屏的因素是JS和CSS资源和数据请求。如果,html请求得到的内容中直接包含首屏内容所需要内联的CSS和Dom结构。


首屏渲染.drawio (4).png


SSR通过在服务端(BFF)直接完成有内容的HTML组装。webview获取到html内容就可以直接渲染。减少白屏时间和不可交互时间。


3.3.1 增量更新和并行请求


SSR将本来一个简单框架HTML,增加了首屏内容所需要的完整CSS和Dom内容。这样的话,HTML请求的包体积就增大了多。其中:



  • 跟版本相关的样式文件CSS (变更频率低)

  • 跟用户信息相关的Dom内容(变更频率高)


HTML根据内容变更频率进行页面分割如下:


<!DOCTYPE html>
<html lang="en">
<head>
<title>OPPO用户体验评价</title>
<meta charset="UTF-8">
<script content="head">window._time = Date.now()</script>
<meta name="renderer" content="webkit|chrome">
<meta name="format-detection" content="telephone=no" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="x5-orientation" content="portrait">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-COMPATIBLE" content="IE=Edge,chrome=1">
<meta name="nightmode" content="disable">
<meta name="color-scheme" content="light">
<!-- css内联内容开始 -->
<style>
/*http://www.xxx.com/wj-prod/style.css*/
/**
* 替换url的css内容,内容比较多
*/

</style>
<!-- css内联内容结束 -->
</head>
<body>
<!-- dom内容开始 -->
<div id="app">
<!-- 拼接好的html结果 -->
<div>
<span></span>
</div>
</div>
<!-- dom内容结束 -->
<!-- 数据内容开始 -->
<script content="page-data">
// 直出的数据,方便vue、react等框架回填状态,声明式UI才必须
window.syncData = {/**服务端获取的数据**/}
</script>
<!-- 数据内容结束 -->
<script crossorigin="anonymous" src="//cdn.xxx.com/wj-prod/client.bundle.js?_t=1"></script>
</body>
</html>

客户端和BFF层大概工作流程如下:


image.png


首屏渲染.drawio (5).png


手机QQ将这套方案开源了:github.com/Tencent/Vas… (我曾经也是这套方案的参与者和使用者)


3.4 总结


为了更快的渲染出页面,发展了离线包技术、服务器端渲染(SSR)、Webview启动并行等一系列的技术方案,这些技术可以单个使用,也可以组合使用。



  • 对于首次加载的页面,使用服务器端渲染(SSR)和Webview启动并行,是可以很好的解决白屏问题,适用H5活动页面。

  • 对于二次加载的页面,使用离线包技术、服务器端渲染(SSR)和Webview启动并行,可以在不经过网络请求也可以展示页面,适用固定入口客户端页面;


四、解决卡顿


使用过程发现H5网页相比于原生页面,更容卡顿,甚至造成页面卡死的问题。这个章节就主要解决为啥浏览器渲染的H5会比原生卡?Hybrid开发用哪些技术如何解决这个问题?


4.1 浏览器渲染的慢


浏览器技术的发展历程已有超过30年的历史,Chrome内核有超过2400万行代码,有很重的历史包袱。


4.1.2 渲染流程


浏览器渲染页面使用了多线程的架构,发生卡顿的主要原因在:渲染线程和JS引擎线程,他两是互斥的,Javascript长时间执行会导致渲染线程无法工作。
image.png


GUI渲染线程(GUI Thread):



  1. 负责渲染浏览器界面。

  2. 解析HTML、CSS,构建DOM树和CSS规则树,并合成渲染树。

  3. 布局(Layout)和渲染(Paint)页面内容。

  4. 与JS引擎线程互斥,当JS引擎线程执行时GUI渲染线程被挂起,GUI更新会被保存在一个队列中,等JS引擎空闲时立即执行。


JS引擎线程(JS Engine Thread):



  1. 也称为JS内核(在Chrome中为V8)。

  2. 负责解析和执行JavaScript代码。

  3. 单线程设计,JS运行过长会阻塞GUI渲染。


事件触发线程(Event Dispatch Thread):



  1. 用于控制事件循环。

  2. 当事件(如点击、鼠标移动等)被触发时,该线程会将事件放到对应的事件队列中,等待JS引擎线程处理。


合成器线程(Compositor Thread)和光栅线程(Raster Thread):



  1. 这两个线程在渲染器进程中运行,以高效流畅地渲染页面。

  2. 合成器线程负责将不同的图层组合成最终用户看到的页面。

  3. 光栅线程则负责将图层内容转换为位图,以便在屏幕上显示。


以用户点击操作为例:


image.png


如果界面的刷新帧率是60帧,在不掉帧的情况。执行时间只有 1000 ms / 60 = 16.66 ms。上图中间的JS引擎线程和渲染线程的执行是串行,而且不能超过16.66 ms。(留给JS引擎和渲染线程执行的时间本身不多,60帧只有有16ms,120帧只有8ms)这就是浏览器为啥比原生渲染卡。


4.2 声明式UI


浏览器渲染慢的主要原因是JS引擎线程和渲染进程的执行互斥, 那么,最简单解决方式就是将渲染线程改造按照帧率来调度,不再等JS引擎线程全部执行完再去渲染。但是,由于浏览器最初涉及的JS引擎线程是为了应对命令式UI渲染方案,命令式UI对界面的修改是不可预测。


4.2.1 命令式UI


命令式UI关注于如何达到某个特定的用户界面状态,通过编写具体的操作指令来直接操纵界面元素。关注于操作步骤和过程,需要编写具体的代码来实现每个步骤。


// dom找到需要变更的节点
const list = document.querySelector('#content')
// 修改样式
list.style.display = 'none'
// 增加内容
list.innerHTML += `<div class="item">列表内容</div>`

优点: 是入门简单,讲究一个精确控制直接操作。


缺点: 直接操作界面,带来对UI界面渲染的不可以预测性;


4.2.1 声明式UI


声明式UI(Declarative UI)是一种用户界面编程范式,它关注于描述UI的期望状态,而不是直接编写用于改变UI的命令。在声明式UI中,开发者通过声明性的方式定义UI的结构、样式和行为,而具体的渲染和更新工作则由框架或库自动完成。


声明式UI编程范式:


image.png


function List(people) {
const listItems = people.map(person =>
<li key={person.id}>
<img
src={getImageUrl(person)}
alt={person.name}
/>

<p>
<b>{person.name}</b>
{' ' + person.profession + ' '}
known for {person.accomplishment}
</p>
</li>

);
return <ul>{listItems}</ul>;
}

优点: 入门难度有所增加,代码更加简洁,带来更高和可维护性,可以直接根据数据预测UI更新


缺点: 入门难度有所增加,灵活性没有命令式UI高;


4.2.1 虚拟DOM


声明式UI强调数据驱动UI更新,一般声明式UI框架中,都还会引入虚拟DOM技术。虚拟DOM(Virtual DOM)是一种在前端开发中广泛使用的技术,它通过JavaScript对象来模拟真实的DOM结构,从而优化Web应用程序的性能和渲染效率。



  • 核心思想:将页面的状态抽象为JavaScript对象表示,避免直接操作真实的DOM,从而提高性能和渲染效率。

  • 工作流程:



    • 初始渲染:首先,通过JavaScript对象(虚拟DOM)表示整个页面的结构。这个虚拟DOM是一个轻量级的映射,保存着真实DOM的层次结构和信息。

    • 更新状态:当应用程序的状态发生变化时,如用户交互或数据更新,虚拟DOM会被修改。这个过程操作的是内存中的JavaScript对象,而不是直接操作真实的DOM。

    • 生成新的虚拟DOM:状态变化后,会生成一个新的虚拟DOM,反映更新后的状态。

    • 对比和更新:通过算法(如Diff算法)将新的虚拟DOM与旧的虚拟DOM进行对比,找出它们之间的差异。

    • 生成变更操作:根据对比结果,找出需要更新的部分,并生成相应的DOM操作(如添加、删除、修改节点等)。

    • 应用变更:将生成的DOM操作应用到真实的DOM上,只更新需要变更的部分,而不是整个页面重新渲染。




virtual-dom为例,虚拟Dom的渲染流程大致如下:


import h from 'virtual-dom/h'
import diff from 'virtual-dom/diff'
import patch from 'virtual-dom/patch'

// 第一步:定义渲染函数,UI = F( state)中的f,
// 开发人员编写渲染模版(react对于是jsx,vue对应的template),由构建工具生成;
function render(count) {
return h('text', { attributes: { count } }, [String(count)])
}

// 第二步:初始化vtree
let tree = render(count) // We need an initial tree

// UI变更
setTimeout(function () {
// 第三步:更新state,重新生成vtree
count++
const newTree = render(count)

// 第四步:对比新旧vtree的差异
const patches = diff(tree, newTree)
console.info('patches', patches)

// 第五步:增量更新dom
// patch(rootNode, patches)

tree = newTree
}, 1000)

相比于命令式UI的开发,声明式UI和虚拟DOM技术结合后,UI渲染过程表示用简单的数据结构就可以表述(第四步骤得到结果序列化),能序列化的好处就是可以很简单完成跨线程处理。


4.3 React Native


声明式UI和虚拟DOM是由React带到开发的视野中。虚拟DOM除了提供声明式UI的高性能渲染能力,它还有一个强大的能力--抽象能力。



4.3.1 组件抽象


在开发者的代码与实际的渲染之间加入一个抽象层,这就可以带来很多可能性。对于React Native 渲染实现:



  • 在IOS平台中则调用Objective-C 的API 去渲染iOS 组件;

  • 在Android平台则调用Java API 去渲染Android 组件,而不是渲染到浏览器DOM 上。


image.png


React Native的渲染是使用不同的平台UI Manager 来渲染UI。因此,React Native对UI开发的基础组件进行整合和对应


React NativeAndroid ViewIOS ViewWeb Dom
<view><ViewGr0up><UIView<div>
<Text><TextView><UITextView><p>
<Image><ImageView><UIImageView><img>

4.3.2 样式渲染


组件结构通过抽象的基础可以完成每个平台的转换。UI界面开发出来结构还需要样式编写。React Native引用了Yoga。Yoga是 C语言写的一个 CSS3/Flexbox 的跨平台 实现的Flexbox布局引擎,意在打造一个跨iOS、Android、Windows平台在内的布局引擎,兼容Flexbox布局方式,让界面布局更加简单。


4.3.3 线程模型


在React Native中,渲染由一个JS线程和原生线程。JS线程负责解析和执行JavaScript代码,而原生线程则负责渲染界面和执行原生操作。JS执行的结果(dom diff)异步通知原生层。


image.png


4.3.3 总结


React Native借助虚拟DOM的抽象能力,把逻辑层的JS代码执行单独抽到JS引擎中执行,不再与UI渲染互斥,可以留更多时间给UI渲染线程。


UI渲染相比浏览器渲染性能提升主要在两点:



  • JS层不再互斥UI渲染;

  • UI渲染由浏览器渲染改成原生渲染;


UI放到Natie层渲染,逻辑放在JS层执行,Natice层与JS层通过JSBridge(24年底会默认替换成JSI,以提高数据通信性能,有兴趣可以去了解)进行通信。


Weex和快应用的实现原理跟React Native类似,主要的差异是在编写声明式UI的DSL,这里就不一一讲解


4.4 微信小程序


微信小程序是从公众号的H5演变而来的。2015年微信对外发布JS-SDK(JS Bridge)提供微信的原生能力(类似早期的phoneGap的),解决了移动网页能力不足的问题。但是,页面加载白屏、网页安全和卡顿问题依旧没被解决。


微信在2017年设计一个全新的系统来解决这些问题,它需要使得所有的开发者都能做到:



  • 快速的加载

  • 更强大的能力

  • 原生的体验

  • 易用且安全的微信数据开放

  • 高效和简单的开发


4.4.1 双线程架构


有了虚拟DOM这个抽象层,UI界面开发的的逻辑层和视图层可以分离。小程序的渲染层和逻辑层分别由两个线程管理(视图层是 WebView,逻辑层是 JS 引擎


image.png



  • 视图层主要负责页面的渲染,每一个页面Page View对应一个Webview(不能超过10个页面栈)。

  • 逻辑层负责js的执行,一个JS执行的沙箱环境;


微信小程序的双线程有如下主要优点:



  1. javascript脚本执行不会抢占ui渲染资源,使整体页面渲染更快;

  2. 每个PageView是由一个webview单独渲染,页面切换效果上更接近原生,比公众号h5网页浏览体验要好;

  3. 安全管控,独立的沙箱环境运行javascript逻辑代码,避免了浏览器的开放api操作dom、跳转页面等,更加安全。


4.4.2 开发的DSL


小程序包含一个描述整体程序的 app 和多个描述各自页面的 page。


个小程序主体部分由三个文件组成,必须放在项目的根目录,如下:


文件必需作用
app.js小程序逻辑
app.json小程序公共配置
app.wxss小程序公共样式表

一个小程序页面由四个文件组成,分别是:


文件类型必需作用
js页面逻辑
wxml页面结构
json页面配置
wxss页面样式表

WXML和WXSS是微信官方创造的DSL,需要进行编译后才能被Webview解析执行。可以从微信开发者工具包文件中找到 wcc 和 wcsc 两个编译工具



  • wcc 编译器可以将 wxml 文件编译成 JS 文件

  • wcsc 编译器可以将 wxss 文件编译成 JS 文件。


 
WXML(WeiXin Markup Language)是框架设计的一套标签语言,结合基础组件事件系统,可以构建出页面的结构。(类比虚拟DOM中的Render函数)


<!--wxml-->
<view>
<text class="text">{{message}}</text>
</view>

将wcc拷贝到当前的index.wxml同级目录, 执行


./wcc -js index.wxml >> wxml.js

将wxml.js的内容复制到浏览器的console中执行后,输入:


$gwx('index.wxml')({
message: 'hello world'
})

可以获得vtree:


{
"tag": "wx-page",
"children": [
{
"tag": "wx-view",
"attr": {},
"children": [
{
"tag": "wx-text",
"attr": {
"class": "text"
},
"children": [
"hello world"
],
"raw": {},
"generics": {}
}
],
"raw": {},
"generics": {}
}
]
}

WXSS (WeiXin Style Sheets)是一套样式语言,用于描述 WXML 的组件样式。(跟CSS类似,增加了rpx相对尺寸,可以参考REM的响应式布局)


page{
display:flex;
background-color: #fff;
}
.wrap{
width:320rpx;
height: 200rpx;
}
.text{
color:red;
font-size:12px
}

将wcsc拷贝到当前的index.wxss同级目录, 执行


./wcsc -js index.wxss >> wxss.js

最后将wxss.js的内容拷贝到浏览器去运行,即可得到:


image.png


(page的样式转化成了body,rpx转成px)


4.4.3 逻辑层和渲染层


逻辑层主要执行app.js和每个页面Page构造器。最终将Page中data修改后的结果通过setData同步给渲染进程。


image.png


逻辑层是一个沙箱的执行环境,该环境不存在DOM API、window、document等对象API和全局对象。换句话来说,小程序相比传统H5是更加安全。小程序中访问用户相关信息是不能像H5直接调用浏览器API,需要经过用户授权才或者由用户操作触发才可以被调用。


小程序的渲染层是在webview执行的,主要将运行wxml和wxss编译后的代码;



  • wxss文件编译成js,之后后会往head中插入style样式

  • wxml编译成声明式UI的render函数,接受逻辑层的data来更新vtree,dom diff ,增量更新dom


render函数中的data由逻辑层调用setData跨线程传给渲染层, 渲染层相比传统的浏览器渲染页面少了渲染前的data生成。相比React Native,渲染层仍然会执行JS(主要虚拟Dom更新)。


image.png


逻辑层和渲染层的在不同平台的实现方式:


运行环境逻辑层渲染层
iOSJavaScriptCoreWKWebView
AndroidV8XWeb(腾讯自研,基于Mobile Chrome内核)
PCChrome内核Chrome内核
小程序开发工具NW.jsChrome WebView

4.4.4 Skyline渲染引擎


小程序早期的渲染层是使用webview,每个PageView对一个webview,内存开销是很多。



Skyline渲染引擎其实可以被看作一个被优化后的webview,并在其内置了更加优秀的动画系统、跨线程传说方案



微信增加了渲染引擎 Skyline,其使用更精简高效的渲染管线,并带来诸多增强特性,让 Skyline 拥有更接近原生渲染的性能体验。


image.png


Skyline 创建了一条渲染线程来负责 Layout, Composite 和 Paint 等渲染任务,并在 AppService 中划出一个独立的上下文,来运行之前 WebView 承担的 JS 逻辑、DOM 树创建等逻辑。这种新的架构相比原有的 WebView 架构,有以下特点:



  • 界面更不容易被逻辑阻塞,进一步减少卡顿

  • 无需为每个页面新建一个 JS 引擎实例(WebView),减少了内存、时间开销

  • 框架可以在页面之间共享更多的资源,进一步减少运行时内存、时间开销

  • 框架的代码之间无需再通过 JSBridge 进行数据交换,减少了大量通信时间开销


 Skyline 的首屏时间比 WebView 快 66%


image.png


Skyline 的内存占用比 WebView 减少 50%


image.png


详细可以参考:developers.weixin.qq.com/miniprogram…


4.4.5 总结


微信小程序采用双线程的架构方案,即解决web困扰已久的安全问题,而且也在一定程度上优化了页面渲染性能。虚拟DOM的抽象能力,使得PageView可以是WebView、React-Native-Like、Flutter 等来渲染


微信小程序也有类似离线包的技术,将用户访问的小程序缓存在微信APP的安装目录中,来解决页面白屏问题。首次加载白屏问题通过native层loading页面来遮盖,因此,小程序首次使用也会有2到3秒的加载过程(小程序分包要求,加载包不能超过2M,加载时间可以做到可控😄)。。


4.5 总结


React Native、Weex、微信小程序、快应用等技术,提供了一整套开发完备的技术和工具来实现混合开发。包括不限于:



  • 平台提供基础UI组件为基础;

  • 声明式UI作为首选,虚拟DOM的抽象能力,UI渲染框架可以多层级多语言实现;

  • 双线程和JSBridge(JSI),使得JS逻辑执行和UI渲染分离;

  • 完整工具类,编译、打包、HMR;

  • 分包,一个应用可以由多个模块包组成;

  • 亚秒级别的热更新能力;


后面出现的Flutter、ArkUI框架也基本围绕这些技术理念进行整合(当然还有编译技术的优化JIT向AOT,带来更快的启动速度)。


(Flutter、ArkTS带来更快的启动速度的技术方案后面再补到文章内吧)


五、发展历程


混合开发的发展史是一段技术革新和演进的过程,它标志着移动应用开发从单一平台向跨平台、高效率的方向转变。


image.png



  • JSBridge让JavaScript拥有原生能力,JSI等技术让JavaScript直面C++,带来更加高效的传输速度;

  • 离线包技术,兼顾加载和留存,SRR仍是很有效优化首屏速度的手段;

  • 分包技术是提高加载速度和开发效率;

  • 声明式U开发范式,加上虚拟Dom抽象能力,解偶上层开发与底层渲染框架,新的渲染框架不断涌现;

  • JSCore引擎的双线程架构,打破逻辑层和UI层间的互斥,即解决Web困扰已久的安全问题,也缓解浏览器渲染性能问题;


作者:azuo
来源:juejin.cn/post/7382051737362284559
收起阅读 »

扒一扒uniapp是如何做ios app应用安装的

为何要扒 因为最近有移动端业务的需求,用uniapp做了ios、Android双端的app应用,由于没有资质上架AppStore和test flight,所以只能使用苹果的超签(需要ios用户提供uuid才能加入测试使用,并且只支持100人安装使用)。打包出来...
继续阅读 »

为何要扒


因为最近有移动端业务的需求,用uniapp做了ios、Android双端的app应用,由于没有资质上架AppStore和test flight,所以只能使用苹果的超签(需要ios用户提供uuid才能加入测试使用,并且只支持100人安装使用)。打包出来生成的是一个ipa包,并不能直接安装,要通过爱思助手这类的应用装一下ipa包。但交付到客户手上就有问题了,还需要电脑连接助手才能安装,那岂不是每次安装新版什么的,都要打开电脑搞一下。因此,才有了这次的扒一扒,目标就是为了解决只提供一个下载链接用户即可下载,不用再通过助手类应用安装ipa包。




开干


官方模板




先打开uniapp云打包一下项目看看


image-20230824112232275.png




复制地址到移动端浏览器打开看看


image-20230824112410817.png


这就对味了,都知道ios是不能直接打开ipa文件进行安装的,接下来就研究下这个页面的执行逻辑。




开扒




F12打开choromdevtools,ctrl+s保存网页html。


image.png


保存成功,接下来看看html代码(样式代码删除了)


    <!DOCTYPE html>
<!-- saved from url=(0077)https://ide.dcloud.net.cn/build/download/2425a4b0-4229-11ee-bd1b-67afccf2f6a7 -->
<html>
   <head>
       <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
  <meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0, user-scalable=no, width=device-width">
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black">
 </head>

<body>
<br><br>
   <center>
       <a class="button" href="itms-services://?action=download-manifest&amp;url=https://ide.dcloud.net.cn/build/ipa-xxxxxxxxxxx.plist">点击安装</a>
   </center>
   <br><br>
   <center>注意:只提供包名为io.dcloud.appid的包的直接下载安装,如果包名不一致请自行搭建下载服务器</center>
</body>
</html>



解析




从上面代码可以看出,关键代码就一行也就是a标签的href地址("itms-services://?action=download-manifest&url=ide.dcloud.net.cn/build/ipa-x…")


先看看itms-services是什么意思,下面是代码开发助手给的解释


image-20230824113418246.png


大概意思就是itms-services是苹果提供给开发者一个的更新或安装应用的协议,用来做应用分发的,需要指向一个可下载的plist文件地址。




什么又是plist呢,这里再请我们的代码开发助手解释一下


image-20230824113748570.png


对于没接触过ios相关开发的,连plist文件怎么写都不知道,既然如此,那接下来就来扒一下dcloud的pilst文件,看看官方是怎么写的吧。




打开浏览器,copy一下刚刚扒下来的html文件下a标签指向的地址,复制url后面plist文件的下载地址粘贴到浏览器保存到桌面。


image-20230824114108792.png


访问后会出现


image-20230824115354028.png




别担心,这时候直接按ctrl+s可以直接保存一个plist.xml文件,也可以打开devtools查看网络请求,找到ipa开头的请求


image-20230824115609551.png


直接新建一个plist文件,cv一下就好,我这里就选择保存它的plist.xml文件,接下来康康文件里到底是什么


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>items</key>
<array>
<dict>
<key>assets</key>
<array>
<dict>
<key>kind</key>
<string>software-package</string>
<key>url</key>
<string>https://bdpkg-aliyun.dcloud.net.cn/20230824/xxxxx/Pandora.ipa?xxxxxxxx</string>
</dict>
      <dict>
<key>kind</key>
<string>display-image</string>
<key>needs-shine</key>
<false/>
<key>url</key>
<string>https://qiniu-web-assets.dcloud.net.cn/unidoc/zh/uni.png</string>
</dict>
</array>
<key>metadata</key>
<dict>
<key>kind</key>
<string>software</string>
<key>bundle-identifier</key>
<string>xxxxx</string>
<key>title</key>
<string>HBuilder手机应用</string>
</dict>
</dict>
</array>
</dict>
</plist>

直接抓重点,这里存你存放ipa包的地址


image-20230824120013828.png


这里改你应用的昵称


image-20230824120453368.png


这里改图标


image-20230824120509797.png


因篇幅限制,想了解plist的自行问代码助手或者搜索引擎。




为我所用


分析完了,如何为我所用呢,首先按照分析上扒下来的plist文件修改下自身应用的信息,并且需要服务器存放ipa文件,这里我选择了unicloud,开发者可以申请一个免费的空间(想了解更多的自己去dcloud官网看看,说多了有打广告嫌疑),替换好大概如下:


image-20230824155040313.png


将plist文件放到服务器上后,拿到plist的下载地址,打开扒下来的html,将a标签上的url切换成plist文件的下载地址,如图:


image-20230824155306228.png


可以把页面上没用的信息都删掉,保存,再把html放到服务器上,用户访问这个地址,就可以直接下载描述文件安装ipa包应用了(记得需要添加用户的uuid到开发者账号上),其实至此需求已经算是落幕了,但转念想想还是有点麻烦,于是又优化了一下,将a标签中的href信息,直接加载到二维码上供用户扫描便可直接下载,相对来说更方便一点,于是我直接打开草料,生成了一个二维码,至此,本次扒拉过程结束,需求落幕!


作者:廿一c
来源:juejin.cn/post/7270799565963149324
收起阅读 »

优雅解决uniapp微信小程序右上角胶囊菜单覆盖问题

前言 大家好,今天聊一下在做uniapp多端适配项目,需要用到自定义导航时,如何解决状态栏塌陷及导航栏安全区域多端适配问题,下文只针对H5、APP、微信小程序三端进行适配,通过封装一个通用高阶组件包裹自定义导航栏内容,主要是通过设置padding来使内容始终保...
继续阅读 »

前言


大家好,今天聊一下在做uniapp多端适配项目,需要用到自定义导航时,如何解决状态栏塌陷及导航栏安全区域多端适配问题,下文只针对H5、APP、微信小程序三端进行适配,通过封装一个通用高阶组件包裹自定义导航栏内容,主要是通过设置padding来使内容始终保持在安全区域,达到低耦合,可复用性强的效果。


一、创建NavbarWrapper.vue组件


大致结构如下:




<script>
export default {
name: 'NavbarWrapper',
data() {
return {
// 像素单位
pxUnit: 'px',
// 默认状态栏高度
statusBarHeight: 'var(--status-bar-height)',
// 微信小程序右上角的胶囊菜单宽度
rightSafeArea: 0
}
}
}
script>


<style scoped>
.navbar-wrapper {
/**
* 元素的宽度和高度包括了内边距(padding)和边框(border),
* 而不会被它们所占据的空间所影响
* 子元素继承宽度时,只会继承内容区域的宽度
*/

box-sizing: border-box;
}
style>


目的


主要是动态计算statusBarHeight和rightSafeArea的值。


解决方案


APP端只需一行css代码即可


.navbar-wrapper {
padding-top: var(--status-bar-height);
}

下面是关于--status-bar-height变量的介绍:


image.png


从上图可以知道--status-bar-height只在APP端是手机实际状态栏高度,在微信小程序是固定的25px,并不是手机实际状态栏高度;


微信小程序时,除了状态栏高度还需要获取右上角的胶囊菜单所占宽度,保持导航栏在安全区域。


以下使用uni.getWindowInfo()uni.getMenuButtonBoundingClientRect()来分别获取状态栏高度和胶囊相关信息,api介绍如下图所示:


image.png


image.png


主要逻辑代码


在NavbarWrapper组件创建时,做相关计算


created() {
const px = this.pxUnit
// #ifndef H5
// 获取窗口信息
const windowInfo = uni.getWindowInfo()
this.statusBarHeight = windowInfo.statusBarHeight + px
// #endif

// #ifdef MP-WEIXIN
// 获取胶囊左边界坐标
const { left } = uni.getMenuButtonBoundingClientRect()
// 计算胶囊(包括右边距)占据屏幕的总宽度:屏幕宽度-胶囊左边界坐标
this.rightSafeArea = windowInfo.windowWidth - left + px
// #endif
}

用法


<NavbarWrapper>
<view class="header">headerview>
NavbarWrapper>

二、多端效果展示


微信小程序


b15a0866000c13e58259645f2459440.jpg


APP端


45ee33b12dcf082e5ac76dc12fc41de.jpg


H5端


22b1984f8b21a4cb79f30286a1e4161.jpg


三、源码


NavbarWrapper.vue




<script>
export default {
name: 'NavbarWrapper',
data() {
return {
// 像素单位
pxUnit: 'px',
// 默认状态栏高度
statusBarHeight: 'var(--status-bar-height)',
// 微信小程序右上角的胶囊菜单宽度
rightSafeArea: 0
}
},
created() {
const px = this.pxUnit
// #ifndef H5
// 获取窗口信息
const windowInfo = uni.getWindowInfo()
this.statusBarHeight = windowInfo.statusBarHeight + px
// #endif

// #ifdef MP-WEIXIN
// 获取胶囊左边界坐标
const { left } = uni.getMenuButtonBoundingClientRect()
// 计算胶囊(包括右边距)占据屏幕的总宽度:屏幕宽度-胶囊左边界坐标
this.rightSafeArea = windowInfo.windowWidth - left + px
// #endif
}
}
script>


<style scoped>
.navbar-wrapper {
/**
* 元素的宽度和高度包括了内边距(padding)和边框(border),
* 而不会被它们所占据的空间所影响
* 子元素继承宽度时,只会继承内容区域的宽度
*/

box-sizing: border-box;
background-color: deeppink;
}
style>




作者:vilan_微澜
来源:juejin.cn/post/7309361597556719679
收起阅读 »

uniApp新模式: 使用Vue3 + Vite4 + Pinia + Axios技术栈构建

背景 使用Vue3 + Vite4 + Pinia + Axios + Vscode模式开发之后,感叹真香!不用再单独去下载HBuilderX。废话不多说,直接上干货! 版本号 node: v16.18.0 vue: ^3.3.4, vite: 4.1.4 ...
继续阅读 »

背景


使用Vue3 + Vite4 + Pinia + Axios + Vscode模式开发之后,感叹真香!不用再单独去下载HBuilderX。废话不多说,直接上干货!


版本号



  • node: v16.18.0

  • vue: ^3.3.4,

  • vite: 4.1.4

  • sass: ^1.62.1

  • pinia: 2.0.36

  • pinia-plugin-unistorage: ^0.0.17

  • axios: ^1.4.0

  • axios-miniprogram-adapter: ^0.3.5

  • unplugin-auto-import: ^0.16.4


如遇到问题,请检查版本号是否一致!!!


项目目录结构


└── src # 主目录
├── api # 存放所有api接口文件
│ ├── user.js # 用户接口
├── config # 配置文件
│ ├── net.config.js # axios请求配置
├── pinia-store # 配置文件
│ ├── user.js # axios请求配置
├── utils # 工具类文件
│ ├── request.js # axios请求封装


开发流程


建议去uni-preset-vue仓库下载vite分支zip包,熟练ts的童鞋下载vite-ts


安装



  • 下载之后进入项目


cd uni-preset-vue


  • 安装依赖


# pnpm
pnpm install
# yarn
yarn
# npm
npm i

运行


pnpm dev:mp-weixin

打开微信开发者工具,找到dist/dev/mp-weixin运行,可以看到默认的页面


安装pinia


pnpm add pinia 

使用pinia


src目录下构建 pinia-store/user.js文件


/**
* @description 用户信息数据持久化
*/

import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
state() {
return {
userInfo: {}
}
},
actions: {
setUserInfo(data) {
this.userInfo = data
}
}
})


  • 修改main.js文件


import {
createSSRApp
} from "vue";
import * as Pinia from 'pinia';
import App from "./App.vue";
export function createApp() {
const app = createSSRApp(App);
const store = Pinia.createPinia();
app.use(store);

return {
app,
Pinia
};
}

pinia数据持久化


安装pinia-plugin-unistorage


pnpm add pinia-plugin-unistorage

修改main.js文件,增加如下代码:


// pinia数据持久化
import { createUnistorage } from 'pinia-plugin-unistorage'
store.use(createUnistorage());
app.use(store);

完整代码如下:


import { createSSRApp } from "vue";

import * as Pinia from 'pinia';
// pinia数据持久化
import { createUnistorage } from 'pinia-plugin-unistorage'
import App from "./App.vue";
export function createApp() {
const app = createSSRApp(App);

const store = Pinia.createPinia();
store.use(createUnistorage());
app.use(store);

return {
app,
Pinia
};
}


在页面中使用:


<script setup>
import { useUserStore } from '@/pinia/user.js'
const user = useUserStore()

// 设置用户信息
const data = { userName: 'snail' }
user.setUser(data)
// 打印用户信息
console.log(user.userInfo)
</script>

安装axios


pnpm add axios

适配小程序,需要另外安装axios-miniprogram-adapter插件


pnpm add axios-miniprogram-adapter

使用axios


utils创建utils/request.js文件


import axios from 'axios';
import mpAdapter from "axios-miniprogram-adapter";
axios.defaults.adapter = mpAdapter;
import { netConfig } from '@/config/net.config';
const { baseURL, contentType, requestTimeout, successCode } = netConfig;

let tokenLose = true;

const instance = axios.create({
baseURL,
timeout: requestTimeout,
headers: {
'Content-Type': contentType,
},
});

// request interceptor
instance.interceptors.request.use(
(config) => {
// do something before request is sent
return config;
},
(error) => {
// do something with request error
return Promise.reject(error);
}
);

// response interceptor
instance.interceptors.response.use(
/**
* If you want to get http information such as headers or status
* Please return response => response
*/

(response) => {
const res = response.data;

// 请求出错处理
// -1 超时、token过期或者没有获得授权
if (res.status === -1 && tokenLose) {
tokenLose = false;
uni.showToast({
title: '服务器异常',
duration: 2000
});

return Promise.reject(res);
}
if (successCode.indexOf(res.status) !== -1) {
return Promise.reject(res);
}
return res;
},
(error) => {
return Promise.reject(error);
}
);

export default instance;


其中net.config.js文件需要在src/config目录下创建,完整代码如下:


/**
* @description 配置axios请求基础信息
* @author hu-snail 1217437592@qq.com
*/

export const netConfig = {
// axios 基础url地址
baseURL: 'https://xxx.cn/api',
// 为开发服务器配置 CORS。默认启用并允许任何源,传递一个 选项对象 来调整行为或设为 false 表示禁用
cors: true,
// 根据后端定义配置
contentType: 'application/json;charset=UTF-8',
//消息框消失时间
messageDuration: 3000,
//最长请求时间
requestTimeout: 30000,
//操作正常code,支持String、Array、int多种类型
successCode: [200, 0],
//登录失效code
invalidCode: -1,
//无权限code
noPermissionCode: -1,
};

src目录下创建src/api/user.jsapi文件


import request from '@/utils/request'

/**
* @description 授权登录
* @param {*} data
*/

export function wxLogin(data) {
return request({
url: '/wx/code2Session',
method: 'post',
params: {},
data
})
}

/**
* @description 获取手机号
* @param {*} data
*/

export function getPhoneNumber(data) {
return request({
url: '/wx/getPhoneNumber',
method: 'post',
params: {},
data
})
}


在页面中使用


<script setup>
import { wxLogin, getPhoneNumber } from '@/api/user.js'
/**
* @description 微信登录
*/

const onWxLogin = async () => {
uni.login({
provider: 'weixin',
success: loginRes => {
state.wxInfo = loginRes
const jsCode = loginRes.code
wxLogin({jsCode}).then((res) => {
const { openId } = res.data
user.setUserInfo({ openId })
})
}
})
}

</script>

配置vue自动导入


安装unplugin-auto-import插件


pnpm add unplugin-auto-import -D

修改vite.config.js文件:


import AutoImport from 'unplugin-auto-import/vite'
plugins: [
AutoImport({
imports: ["vue"]
})
],

页面中使用,需要注意的事每次导入新的vue指令,需要重新运行!!


<script setup>
onBeforeMount(() => {
console.log('----onBeforeMount---')
})
</script>

安装uni-ui


pnpm add @dcloudio/uni-ui

使用uni-ui


修改pages.json文件,增加如下代码:


"easycom": {
"autoscan": true,
"custom": {
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
}
},

在页面中使用


<template>
<uni-icons type="bars" size="16"></uni-icons>
</template>

到此已基本可以完成程序的开发,其他功能按照自己的需求做增删改查即可!


作者:蜗牛前端
来源:juejin.cn/post/7244192313844154424
收起阅读 »

Flutter:听说你最近到处和人说我解散了?

早上收到这条消息的时候我是懵圈的,明明几天前才收到下个月 Google I/O 时的 Flutter community 邮件,难道还能这样“出师未捷身先死” ?那 I/O 还开不开? 懵逼之余我又收到了另一条私信,结合起来大概理解了,Google 的裁员...
继续阅读 »

早上收到这条消息的时候我是懵圈的,明明几天前才收到下个月 Google I/O 时的 Flutter community 邮件,难道还能这样“出师未捷身先死” ?那 I/O 还开不开?


图片


图片


懵逼之余我又收到了另一条私信,结合起来大概理解了,Google 的裁员在一定程度上影响到了 Flutter Team ,而传着传着就变成了 「Google 解散 Flutter Team」 。。。。


图片


事实上大概10来天前谷歌就开启了新一轮的裁员计划,当时就提到谷歌正在实施新一轮裁员,试图削减成本并优化整个财务部门的运营,主要是作为谷歌内部重组的一部分,算是 1 月份裁员的延续,不过当时我也没在意,Flutter Team 会受到影响是必然的,毕竟 Flutter Team 规模不算小,只是没想到会变成 「Google 解散 Flutter 」这样的说法。



http://www.business-standard.com/companies/n…



image.png


而这次裁员计划里,Flutter Team 果然又受到波及,其实去年谷歌大裁员里 Flutter Team 也是受到波及,而结果就是 PC 端的推进陷入了一定程度的迟缓,还有无障碍相关的部分,总的来说,2023 年里 Flutter Team 里确实离开了不少元老和大神,但是其实一年下来,Flutter 整体并没有受到太大拖累。



而这次 Flutter Team 的裁员规模和波及范围还暂不明朗,但是人数应该不会太少,所以也不好说影响范围,但是有一点需要提的是, Flutter 是一个开源项目,总的来说他需要 Google 的投入和 Flutter Team 来维护,但是他的推进更主要还是来自社区里的广大开发者,例如国内的 AlexV525、luckysmg 等大佬的加持。


图片


当然,你说现在 Flutter Team 是否是因为人员冗余而裁员,我倒是觉得并不会,因为目前需要解决的问题和推进的 roadmap 其实很多,特别是 Flutter 的全平台特性,甚至近期开始落地的 Wasm Native ,这些都是需要大量时间和人力投入。



图片


所以裁员肯定多多少少会影响 Flutter 的计划,但是那也和「解散」不沾边,就是在大家全力准备 I/O 的时候来 layoffs ,多多少少还是有点不大“人道”的味道。


图片


图片


图片


不管怎么说,从去年开始,不管是国内还是国外,裁员基本都是主流,大家都在为社会贡献人才,只能说大环境如此,只是我是没想道两天不到就会传播成 「Google 要解散 Flutter 团队」,那再过几天会不会还冒出来 「 Flutter 凉凉,鸿蒙接手 Flutter 」的内容?


图片


不管怎么说,这些年 Flutter 算是 Google 比较大投入的项目,开猿节流时受到影响也很正常,如果还不放心,或者你可以看看下个月马上就要开 Google I/O ,来看下 Flutter 会再给你画什么饼:io.google/2024/intl/z…


图片


作者:恋猫de小郭
来源:juejin.cn/post/7362901975421337651
收起阅读 »

各平台移动开发技术对比

针对原生开发面临的问题,业界一直都在努力寻找好的解决方案,而时至今日,已经有很多跨平台框架(注意,本书中所指的“跨平台”若无特殊说明,即特指 Android 和 iOS 两个平台),根据其原理,主要分为三类: hybrid :H5 + 原生(Cordova、I...
继续阅读 »

针对原生开发面临的问题,业界一直都在努力寻找好的解决方案,而时至今日,已经有很多跨平台框架(注意,本书中所指的“跨平台”若无特殊说明,即特指 Android 和 iOS 两个平台),根据其原理,主要分为三类:


hybrid :H5 + 原生(Cordova、Ionic、微信小程序)

JavaScript 开发 + 原生渲染 (React Native、Weex)

自绘UI + 原生 (Qt for mobile、Flutter)


1、Hybrid :H5 + 原生


主要原理:
将 App 中需要动态变动的内容通过HTML5(简称 H5)来实现,
通过原生的网页加载控件WebView (Android)或 WKWebView(iOS)来加载。
WebView 中 JavaScript 与原生 API 之间就需要一个通信的桥梁,JsBridge。


**优点是:**动态内容可以用 H5开发,而H5是Web 技术栈,Web技术栈生态开放且社区资源丰富,整体开发效率高。


缺点是:

1.性能体验不佳,对于复杂用户界面或动画,WebView 有时会不堪重任。

2.其 JavaScript 依然运行在一个权限受限的沙箱中,所以对于大多数系统能力都没有访问权限,如无法访问文件系统、不能使用蓝牙等。所以,对于 H5 不能实现的功能,就需要原生去做了。


2、JavaScript开发 + 原生渲染 (React Native、Weex)


2.1、React Native


1.React Native (简称 RN )是 Facebook 开源的跨平台移动应用开发框架。
目前支持 iOS 和 Android 两个平台。
2.React Native 基于 JavaScript,开发者可以利用已有的前端开发经验快速上手
3.开发者编写的js代码,通过 react native 的中间层转化为原生控件和操作
4.react native 运行在JavaCore中,所以不存在浏览器兼容的问题
最终,JS代码会被打包成一个 bundle 文件,自动添加到 App 的资源目录下。



JavaScriptCore 是一个JavaScript解释器,它在React Native中主要有两个作用:



  1. 为 JavaScript 提供运行环境。

  2. 是 JavaScript 与原生应用之间通信的桥梁,作用和 JsBridge 一样,事实上,在 iOS 中,很多 JsBridge 的实现都是基于 JavaScriptCore 。




而 RN 中将虚拟 DOM 映射为原生控件的过程主要分两步:



  1. 布局消息传递; 将虚拟 DOM 布局信息传递给原生;

  2. 原生根据布局信息通过对应的原生控件渲染;



RN 和 React 原理相通,React 是一个响应式的 Web 框架。



  • 开发者只需关注状态转移(数据),当状态发生变化,React 框架会自动根据新的状态重新构建UI。

  • React 框架在接收到用户状态改变通知后,会根据当前渲染树,结合最新的状态改变,通过 Diff 算法,计算出树中变化的部分,然后只更新变化的部分(DOM操作),从而避免整棵树重构,提高性能


2.2、Weex


1.Weex 是阿里的跨平台移动端开发框架,思想及原理和 React Native 类似
底层都是通过原生渲染的
2.不同是应用层开发语法 (即 DSL,Domain Specific Language):Weex 支持 Vue 语法和 Rax 语法
3.Rax 的 DSL(Domain Specific Language) 语法是基于 React JSX 语法而创造
4.但相对于 React Native,它对前端开发者的要求较低
5、一定程度减少了JS Bundle的体积,使得 bundle 里面只保留业务代码。


JavaScript 开发 + 原生渲染 的方式主要优点如下



  1. 采用 Web 开发技术栈,社区庞大、有前端基础的话上手快、开发成本相对较低。

  2. 原生渲染,性能相比 H5 提高很多。

  3. 动态化较好,支持热更新。


不足:



  1. 渲染时需要 JavaScript 和原生之间通信,在有些场景如拖动可能会因为通信频繁导致卡顿。

  2. JavaScript 为脚本语言,执行时需要解释执行 (这种执行方式通常称为 JIT,即 Just In Time,指在执行时实时生成机器码),执行效率和编译类语言(编译类语言的执行方式为 AOT ,即 Ahead Of Time,指在代码执行前已经将源码进行了预处理,这种预处理通常情况下是将源码编译为机器码或某种中间码)仍有差距。

  3. 由于渲染依赖原生控件,不同平台的控件需要单独维护,并且当系统更新时,社区控件可能会滞后;

    除此之外,其控件系统也会受到原生UI系统限制,例如,在 Android 中,手势冲突消歧规则是固定的,这在使用不同人写的控件嵌套时,手势冲突问题将会变得非常棘手。这就会导致,如果需要自定义原生渲染组件时,开发和维护成本过高。


3、自绘UI + 原生


自绘UI + 原生这种技术的思路是:
通过在不同平台实现一个统一接口的渲染引擎来绘制UI,而不依赖系统原生控件,
所以可以做到不同平台UI的一致性


注意,自绘引擎解决的是 UI 的跨平台问题,如果涉及其他系统能力调用,依然要涉及原生开发。这种平台技术的优点如下:



  1. 性能高;由于自绘引擎是直接调用系统API来绘制UI,所以性能和原生控件接近。

  2. 灵活、组件库易维护、UI外观保真度和一致性高;由于UI渲染不依赖原生控件,也就不需要根据不同平台的控件单独维护一套组件库,所以代码容易维护。由于组件库是同一套代码、同一个渲染引擎,所以在不同平台,组件显示外观可以做到高保真和高一致性;另外,由于不依赖原生控件,也就不会受原生布局系统的限制,这样布局系统会非常灵活。


不足:



  1. 动态性不足;为了保证UI绘制性能,自绘UI系统一般都会采用 AOT 模式编译其发布包,所以应用发布后,不能像 Hybrid 和 RN 那些使用 JavaScript(JIT)作为开发语言的框架那样动态下发代码。

  2. 应用开发效率低:Qt 使用 C++ 作为其开发语言,而编程效率是直接会影响 App 开发效率的,C++ 作为一门静态语言,在 UI 开发方面灵活性不及 JavaScript 这样的动态语言,另外,C++需要开发者手动去管理内存分配,没有 JavaScript 及Java中垃圾回收(GC)的机制。


Flutter 就属于这一类跨平台技术,没错,Flutter 正是实现一套自绘引擎,并拥有一套自己的 UI 布局系统,且同时在开发效率上有了很大突破。


3.1、Qt


Qt 是一个1991年由 Qt Company 开发的跨平台 C++ 图形用户界面应用程序开发框架。


在近几年,虽然偶尔能听到 Qt 的声音,但一直很弱,无论 Qt 本身技术如何、设计思想如何,但事实上终究是败了,究其原因,笔者认为主要有四:


第一:Qt 移动开发社区太小,学习资料不足,生态不好。

第二:官方推广不利,支持不够。

第三:移动端发力较晚,市场已被其他动态化框架占领( Hybrid 和 RN )。

第四:在移动开发中,C++ 开发和Web开发栈相比有着先天的劣势,直接结果就是 Qt 开发效率太低。


3.2、Flutter


Flutter 是 Google 发布的一个用于创建跨平台、高性能移动应用的框架。
Flutter 实现了一个自绘引擎,使用自身的布局、绘制系统。


2021年8月底,已经有 127K  Star,Star 数量 Github 上排名前 20 
Flutter 生态系统得以快速增长,国内外有非常多基于 Flutter 的成功案例。



1.Flutter 采用自己的渲染引擎 Skia,将 UI 渲染到画布上,具有良好的性能表现

2.如果对性能要求较高,特别是需要处理复杂动画和大量图形渲染的场景,建议选择 Flutter。

3.Flutter 则采用 Dart 语言,需要开发人员掌握新的语法和概念。

4.支持iOS、Android、Windows/MAC/Linux等多个平台,且能达到原生性能。(移动端、Web端和PC端)



Flutter和Gt对比:



  1. 生态:Flutter 生态系统发展迅速,社区非常活跃,无论是开发者数量还是第三方组件都已经非常可观。

  2. 技术支持:现在 Google 正在大力推广Flutter,Flutter 的作者中很多人都是来自Chromium团队,并且 Github上活跃度很高。另一个角度,从 Flutter 诞生到现在,频繁的版本发布也可以看出 Google 对 Flutter的投入的资源不小,所以在官方技术支持这方面,大可不必担心。

  3. 开发效率:一套代码,多端运行;并且在开发过程中 Flutter 的热重载可帮助开发者快速地进行测试、构建UI、添加功能并更快地修复错误。在 iOS 和 Android 模拟器或真机上可以实现毫秒级热重载,并且不会丢失状态。这真的很棒,相信我,如果你是一名原生开发者,体验了Flutter开发流后,很可能就不想重新回去做原生了,毕竟很少有人不吐槽原生开发的编译速度。


4、react-native、weex、flutter对比:



三种跨平台技术



react-native、weex、flutter对比


React Native:宣布放弃使用 React Native,回归使用原生技术。主要还是集中于项目庞大之后的维护困难,第三方库的良莠不齐,兼容上需要耗费更多的精力导致放弃。


hybrid:


大家都知道hybrid即为web+native的混合开发模式



优点:就是拥有了web开发的服务端发布即可更新的便捷性,Android和iOS两端可以共用代码,并且web技术已经非常成熟,开发效率也会很高。




缺点:就是众所周知的性能相比native有很大的不足,且不同机型和系统版本下的兼容性较差。



React Native、Weex 和 Flutter 是目前最为热门的混合开发框架,它们各自有着优势和特点:


1、React Native



1.React Native 是由 Facebook 推出的开源框架,拥有庞大而活跃的社区,有大量的第三方组件和库可供使用。

2.React Native 基于 JavaScript,开发者可以利用已有的前端开发经验快速上手

3.开发者编写的js代码,通过 react native 的中间层转化为原生控件和操作

4.react native 运行在JavaCore中,所以不存在浏览器兼容的问题



  1. 最终,JS代码会被打包成一个 bundle 文件,自动添加到 App 的资源目录下。



2、Weex



  • Weex 是阿里巴巴推出的开源项目,也有一个较为活跃的社区,但相对于 React Native 来说,生态系统规模稍小。



1.React Native 和 Weex 使用了 WebView 或类似的机制来渲染应用界面,性能相对较低。

2.Weex 同样基于 JavaScript,但相对于 React Native,它对前端开发者的要求较低

3.开发者可以使用Vue.js和Rax两个前端框架来进行WEEX页面开发

4.和 react native一样,weex 所有的标签也不是真实控件,JS 代码中所生成存的 dom,最后都是由 Native 端解析,再得到对应的Native控件渲染



  1. weex:一定程度减少了JS Bundle的体积,使得 bundle 里面只保留业务代码。



3、Flutter



  • Flutter 是由 Google 开发的开源框架,虽然相对较新,但也有一个迅速增长的社区和生态系统。



1.Flutter 采用自己的渲染引擎 Skia,将 UI 渲染到画布上,具有良好的性能表现

2.如果对性能要求较高,特别是需要处理复杂动画和大量图形渲染的场景,建议选择 Flutter。

3.Flutter 则采用 Dart 语言,需要开发人员掌握新的语法和概念。

4.支持iOS、Android、Windows/MAC/Linux等多个平台,且能达到原生性能。(移动端、Web端和PC端)



4、react-native、weex、flutter对比:



react-native、weex、flutter对比


React Native:宣布放弃使用 React Native,回归使用原生技术。主要还是集中于项目庞大之后的维护困难,第三方库的良莠不齐,兼容上需要耗费更多的精力导致放弃。


作者:码农君
来源:juejin.cn/post/7360586351816638501
收起阅读 »

APP与H5通信-JsBridge

背景 在移动开发领域,原生应用嵌入网页(H5)可以实现一套代码多端使用,那么原生应用(APP)和网页(H5)之间的通信就非常重要。 JsBridge作为一种实现此类通信的工具,用于实现原生应用和嵌入其中的网页之间的通信。 H5与native交互,本质上来说就两...
继续阅读 »

背景


在移动开发领域,原生应用嵌入网页(H5)可以实现一套代码多端使用,那么原生应用(APP)和网页(H5)之间的通信就非常重要。


JsBridge作为一种实现此类通信的工具,用于实现原生应用和嵌入其中的网页之间的通信。


H5与native交互,本质上来说就两种调用:



  1. JavaScript 调用 native 方法

  2. native 调用 JavaScript 方法


JavaScript调用native方法有两种方式:



  1. 注入,native 往 webview 的 window 对象中添加一些原生方法,h5可以通过注入的方法来调用 app 的原生能力

  2. 拦截,H5通过与 native 之间的协议发送请求,native拦截请求再去调用 app 原生能力


本文主要介绍H5端与App(android和ios)之间通信使用方式。


代码实现


实现步骤:


这段代码实现的是 APP(Android 和 iOS) 和 H5 之间的通信。这个通信过程主要依赖于 WebViewJavascriptBridge 这个桥接库。这里是具体的流程:



  1. 初始化 WebViewJavascriptBridge 对象:



    • 对于 Android,如果 WebViewJavascriptBridge 对象已经存在,则直接使用;如果不存在,则在 'WebViewJavascriptBridgeReady' 事件触发时获取 WebViewJavascriptBridge 对象。

    • 对于 iOS,如果 WebViewJavascriptBridge 对象已经存在,直接使用;如果不存在,则创建一个隐藏的 iframe 来触发 WebViewJavascriptBridge 的初始化,并在初始化完成后通过 WVJBCallbacks 回调数组来获取 WebViewJavascriptBridge 对象。



  2. 注册事件:


    提供了 callHandlerregisterHandler 两个方法,分别用于在 JS 中调用 APP 端的方法和注册供 APP 端调用的 JS 方法。


  3. 调用方法:


    当 APP 或 JS 需要调用对方的方法时,只需调用 callHandlerregisterHandler 方法即可。



const { userAgent } = navigator;
const isAndroid = userAgent.indexOf('android') > -1; // android终端

/**
* Android 与安卓交互时:
* 1、不调用这个函数安卓无法调用 H5 注册的事件函数;
* 2、但是 H5 可以正常调用安卓注册的事件函数;
* 3、还必须在 setupWebViewJavascriptBridge 中执行 bridge.init 方法,否则:
* ①、安卓依然无法调用 H5 注册的事件函数
* ①、H5 正常调用安卓事件函数后的回调函数无法正常执行
*
* @param {*} callback
*/

function androidFn(callback) {
if (window.WebViewJavascriptBridge) {
callback(window.WebViewJavascriptBridge);
} else {
document.addEventListener(
'WebViewJavascriptBridgeReady',
() => {
callback(window.WebViewJavascriptBridge);
},
false,
);
}
}

/**
* IOS 与 IOS 交互时,使用这个函数即可,别的操作都不需要执行
*/

function iosFn(callback) {
if (window.WebViewJavascriptBridge) { return callback(window.WebViewJavascriptBridge); }
if (window.WVJBCallbacks) { return window.WVJBCallbacks.push(callback); }
window.WVJBCallbacks = [callback];
const WVJBIframe = document.createElement('iframe');
WVJBIframe.style.display = 'none';
WVJBIframe.src = 'https://__BRIDGE_LOADED__';
document.documentElement.appendChild(WVJBIframe);
setTimeout(() => { document.documentElement.removeChild(WVJBIframe); }, 0);
}

/**
* 注册 setupWebViewJavascriptBridge 方法
* 之所以不将上面两个方法融合成一个方法,是因为放在一起,那么就只有 iosFuntion 中相关的方法体生效
*/

const setupWebViewJavascriptBridge = isAndroid ? androidFn : iosFn;

/**
* 这里如果不做判断是不是安卓,而是直接就执行下面的方法,就会导致
* 1、IOS 无法调用 H5 这边注册的事件函数
* 2、H5 可以正常调用 IOS 这边的事件函数,并且 H5 的回调函数可以正常执行
*/

if (isAndroid) {
/**
* 与安卓交互时,不调用这个函数会导致:
* 1、H5 可以正常调用 安卓这边的事件函数,但是无法再调用到 H5 的回调函数
*
* 前提 setupWebViewJavascriptBridge 这个函数使用的是 andoirFunction 这个,否则还是会导致上面 1 的现象出现
*/

setupWebViewJavascriptBridge((bridge) => {
console.log('打印***bridge', bridge);
// 注册 H5 界面的默认接收函数(与安卓交互时,不注册这个事件无法接收回调函数)
bridge.init((message, responseCallback) => {
responseCallback('JS 初始化');
});
});
}

export default {
// js调APP方法 (参数分别为:app提供的方法名 传给app的数据 回调)
callHandler(name, params, callback) {
setupWebViewJavascriptBridge((bridge) => {
bridge.callHandler(name, params, callback);
});
},

// APP调js方法 (参数分别为:js提供的方法名 回调)
registerHandler(name, callback) {
setupWebViewJavascriptBridge((bridge) => {
bridge.registerHandler(name, (data, responseCallback) => {
callback(data, responseCallback);
});
});
},
};

使用 JSBridge 总结:


1、跟 IOS 交互的时候,只需要且必须注册 iosFuntion 方法即可,不能在 setupWebViewJavascriptBridge 中执行 bridge.init 方法,否则 IOS 无法调用到 H5 的注册函数;


2、与安卓进行交互的时候



  • 使用 iosFuntion,就可以实现 H5 调用 安卓的注册函数,但是安卓无法调用 H5 的注册函数,
    并且 H5 调用安卓成功后的回调函数也无法执行

  • 使用 andoirFunction 并且要在 setupWebViewJavascriptBridge 中执行 bridge.init 方法,
    安卓才可以正常调用 H5 的回调函数,并且 H5 调用安卓成功后的回调函数也可以正常执行了


H5使用


h5获取app返回的数据:


jsBridge.callHandler('getAppUserInfo', { title: '首页' }, (data) => {
console.log('获取app返回的数据', data);
});

app获取h5返回的数据:


 jsBridge.registerHandler('getInfo', (data, responseCallback) => {
console.log('打印***get app data', data);
responseCallback('我是返回的数据');
});


两者都可通信,只要一方使用registerHandler注册了事件,另一方通过callHandler接受数据


总结


主要介绍了原生应用嵌入网页(H5)与APP(android和ios)之间的通信实现方法。


这个通信过程主要依赖于 WebViewJavascriptBridge 这个桥接库。通过在JavaScript中调用native方法和native调用JavaScript方法,实现APP和H5的互通。


主要通过提供了 callHandlerregisterHandler 两个方法,分别用于在 JS 中调用 APP 端的方法和注册供 APP 端调用的 JS 方法。


更简单方式: APP与H5通信-postMessage


参考资料:


ios-webview


android-webview


参考案例


作者:一诺滚雪球
来源:juejin.cn/post/7293728293768855587
收起阅读 »

Flutter 用什么架构方式才合理?

前言 刚入门 Flutter 编程时,差点被 Flutter 的嵌套地狱吓走,不过当我看到 Flutter 支持 Windows 稳定后,于是下定决心尝试接受 Flutter,因为 Flutter 真的给的太多了:跨平台、静态编译、热加载界面。 Flutter...
继续阅读 »

前言


刚入门 Flutter 编程时,差点被 Flutter 的嵌套地狱吓走,不过当我看到 Flutter 支持 Windows 稳定后,于是下定决心尝试接受 Flutter,因为 Flutter 真的给的太多了:跨平台、静态编译、热加载界面。


Flutter 代码是写到文件夹中的,通过文件夹来管理代码,像是 c++ 语言那样,一个文件,即可以写类,也可以直接写方法😠。


不像 java 那样,全部都是类,整齐划一,通过包名来管理,但也支持类似的“导包”😆。


那么怎样才能像 Java 那样,有个框架优化代码,让项目看起来更整洁好维护呢?


我目前的答案是 MVC 🐷,合适自己的架构才是最好的架构,用这个架构,我感觉找到了家,大家先看看我的代码,然后再做评价。


使用部分


结合GetX, 使用方式如下:


import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:wenznote/commons/mvc/controller.dart';
import 'package:wenznote/commons/mvc/view.dart';

class CustomController extends MvcController {
var count = 0.obs;

void addCount() {
count.value++;
}
}

class CustomView extends MvcView<CustomController> {
const CustomView({super.key, required super.controller});

@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: [
Obx(() => Text("点击次数:${controller.count.value}")),
TextButton(
onPressed: () {
controller.addCount();
},
child: Text("点我"),
),
],
),
);
}
}

简单粗暴,直接在 CustomView 中设计 UI, 在 CustomController 中编写业务逻辑代码,比如登录注册之类的操作。


至于 MVC 中的 Model 去哪里了?你猜猜😘。


代码封装部分


代码封装也很简洁,封装的 controller 代码如下


import 'package:flutter/material.dart';

class MvcController with ChangeNotifier {
late BuildContext context;

@mustCallSuper
void onInitState(BuildContext context) {
this.context = context;
}

@mustCallSuper
void onDidUpdateWidget(BuildContext context, MvcController oldController) {
this.context = context;
}

void onDispose() {}
}

封装的 view 代码如下


import 'package:flutter/material.dart';
import 'controller.dart';

typedef MvcBuilder<T> = Widget Function(T controller);

class MvcView<T extends MvcController> extends StatefulWidget {
final T controller;
final MvcBuilder<T>? builder;

const MvcView({
super.key,
required this.controller,
this.builder,
});

Widget build(BuildContext context) {
return builder?.call(controller) ?? Container();
}

@override
State<MvcView> createState() => _MvcViewState();
}

class _MvcViewState extends State<MvcView> with AutomaticKeepAliveClientMixin{
@override
bool get wantKeepAlive => true;

@override
void initState() {
super.initState();
widget.controller.onInitState(context);
widget.controller.addListener(onChanged);
}

void onChanged() {
if (context.mounted) {
setState(() {});
}
}

@override
Widget build(BuildContext context) {
super.build(context);
widget.controller.context = context;
return widget.build(context);
}

@override
void didUpdateWidget(covariant MvcView<MvcController> oldWidget) {
super.didUpdateWidget(oldWidget);
widget.controller.onDidUpdateWidget(context, oldWidget.controller);
}


@override
void dispose() {
widget.controller.removeListener(onChanged);
widget.controller.onDispose();
super.dispose();
}
}

结语


MVC 可以很简单快速的将业务代码和 UI 代码隔离开,改逻辑的时候就去找 Controller 就行,改 UI 的话就去找 View 就行,和后端开发一样的思路,完成作品就行。


附上的作品文件结构截图,亲喷哈~


04ab4670-d62d-11ee-b1e9-af9546993c52.png


感谢大家的关注与支持,后续继续更新更多 flutter 跨平台开发知识,例如:MVC 架构中的 Controller 应该在哪里创建?Controller 中的 Service 应该在哪里创建?


作品地址:github.com/lyming99/we…


作者:果冻橙橙君
来源:juejin.cn/post/7340472228927914024
收起阅读 »

uni-app开发小程序:项目架构以及经验分享

uni-app开发小程序:项目架构以及经验分享 2022年的时候,公司为了快速完成产品并上线,所以选用微信小程序为载体;由于后期还是打算开发App;虽然公司有ios和Android,但是如果能一套代码打包多端,一定程度上可以解决成本;前端技术栈也是vue,在...
继续阅读 »

uni-app开发小程序:项目架构以及经验分享



2022年的时候,公司为了快速完成产品并上线,所以选用微信小程序为载体;由于后期还是打算开发App;虽然公司有iosAndroid,但是如果能一套代码打包多端,一定程度上可以解决成本;前端技术栈也是vue,在考察选择了uni-app。后来多个小程序项目都采用了uni-app开发,积累了一定的经验以及封装了较多业务组件,这里就分享一下uni-app项目的整体架构、常用方法封装以及注意事项。全文代码都会放到github,先赞后看,年入百万!



创建项目


uni-app提供了两种创建项目的方式:




⚠️需要注意的是,一定要根据项目需求来选择项目的创建方式;如果只是单独的开发小程序App,且开发环境单一,可以使用HBuilderX可视化工具创建。如果多端开发,以及同一套代码可能会打包生成多个小程序建议使用vue-cli进行创建,不然后期想搞自动化构建以及按指定条件进行编译比较痛苦。关于按条件编译,文章后面会有详细说明。



使用vue-cli安装和运行比较简单:


1.全局安装 vue-cli


npm install -g @vue/cli

2.创建uni-app


vue create -p dcloudio/uni-preset-vue 项目名称

3.进入项目文件夹


cd 项目名称

4.运行项目,如果是已微信小程序为主,可以在package.json中的命令改为:


"scripts": {
"serve": "npm run dev:mp-weixin"
}

然后执行


npm run serve

使用cli创建项目默认不带css预编译,需要手动安装一下,这里已sass为例:


npm i sass --save-dev
npm i sass-loader --save-dev

整体项目架构


通过HBuilderX或者vue-cli创建的项目,目录结构有稍许不同,但基本没什么差异,这里就按vue-cli创建的项目为例,整体架构配置如下:


    ├──dist 编译后的文件路径
├──package.json 配置项
├──src 核心内容
├──api 项目接口
├──components 全局公共组件
├──config 项目配置文件
├──pages 主包
├──static 全局静态资源
├──store vuex
├──mixins 全局混入
├──utils 公共方法
├──App.vue 应用配置,配置App全局样式以及监听
├──main.js Vue初始化入口文件
├──manifest.json 配置应用名称、appid等打包信息
├──pages.json 配置页面路由、导航条、选项卡等页面类信息
└──uni.scss 全局样式

封装方法


工欲善其事,必先利其器。在开发之前,我们可以把一些全局通用的方法进行封装,以及把uni-app提供的api进行二次封装,方便使用。全局的公共方法我们都会放到/src/utils文件夹下。


封装常用方法


下面这些方法都放在/src/utils/utils.js中,文章末尾会提供github链接方便查看。如果项目较大,建议把方法根据功能定义不同的js文件。


小程序Toast提示


/**
* 提示方法
* @param {String} title 提示文字
* @param {String} icon icon图片
* @param {Number} duration 提示时间
*/

export function toast(title, icon = 'none', duration = 1500) {
if(title) {
uni.showToast({
title,
icon,
duration
})
}
}

缓存操作(设置/获取/删除/清空)


/**
* 缓存操作
* @param {String} val
*/

export function setStorageSync(key, data) {
uni.setStorageSync(key, data)
}

export function getStorageSync(key) {
return uni.getStorageSync(key)
}

export function removeStorageSync(key) {
return uni.removeStorageSync(key)
}

export function clearStorageSync() {
return uni.clearStorageSync()
}

页面跳转


/**
* 页面跳转
* @param {'navigateTo' | 'redirectTo' | 'reLaunch' | 'switchTab' | 'navigateBack' | number } url 转跳路径
* @param {String} params 跳转时携带的参数
* @param {String} type 转跳方式
**/

export function useRouter(url, params = {}, type = 'navigateTo') {
try {
if (Object.keys(params).length) url = `${url}?data=${encodeURIComponent(JSON.stringify(params))}`
if (type === 'navigateBack') {
uni[type]({ delta: url })
} else {
uni[type]({ url })
}
} catch (error) {
console.error(error)
}
}

图片预览


/**
* 预览图片
* @param {Array} urls 图片链接
*/

export function previewImage(urls, itemList = ['发送给朋友', '保存图片', '收藏']) {
uni.previewImage({
urls,
longPressActions: {
itemList,
fail: function (error) {
console.error(error,'===previewImage')
}
}
})
}

图片下载


/**
* 保存图片到本地
* @param {String} filePath 图片临时路径
**/

export function saveImage(filePath) {
if (!filePath) return false
uni.saveImageToPhotosAlbum({
filePath,
success: (res) => {
toast('图片保存成功', 'success')
},
fail: (err) => {
if (err.errMsg === 'saveImageToPhotosAlbum:fail:auth denied' || err.errMsg === 'saveImageToPhotosAlbum:fail auth deny') {
uni.showModal({
title: '提示',
content: '需要您授权保存相册',
showCancel: false,
success: (modalSuccess) => {
uni.openSetting({
success(settingdata) {
if (settingdata.authSetting['scope.writePhotosAlbum']) {
uni.showModal({
title: '提示',
content: '获取权限成功,再次点击图片即可保存',
showCancel: false
})
} else {
uni.showModal({
title: '提示',
content: '获取权限失败,将无法保存到相册哦~',
showCancel: false
})
}
},
fail(failData) {
console.log('failData', failData)
}
})
}
})
}
}
})
}

更多函数就不在文章中展示了,已经放到/src/utils/utils,js里面,具体可以到github查看。


请求封装


为了减少在页面中的请求代码,所以我们要对uni-app提供的请求方式进行二次封装,在/src/utils文件夹下建立request.js,具体代码如下:



import {toast, clearStorageSync, getStorageSync, useRouter} from './utils'
import {BASE_URL} from '@/config/index'

const baseRequest = async (url, method, data, loading = true) =>{
header.token = getStorageSync('token') || ''
return new Promise((reslove, reject) => {
loading && uni.showLoading({title: 'loading'})
uni.request({
url: BASE_URL + url,
method: method || 'GET',
header: header,
timeout: 10000,
data: data || {},
success: (successData) => {
const res = successData.data
uni.hideLoading()
if(successData.statusCode == 200){
if(res.resultCode == 'PA-G998'){
clearStorageSync()
useRouter('/pages/login/index', 'reLaunch')
}else{
reslove(res.data)
}
}else{
toast('网络连接失败,请稍后重试')
reject(res)
}
},
fail: (msg) => {
uni.hideLoading()
toast('网络连接失败,请稍后重试')
reject(msg)
}
})
})
}

const request = {};

['options', 'get', 'post', 'put', 'head', 'delete', 'trace', 'connect'].forEach((method) => {
request[method] = (api, data, loading) => baseRequest(api, method, data, loading)
})

export default request

请求封装好以后,我们在/src/api文件夹下按业务模块建立对应的api文件,拿获取用户信息接口举例子:


/src/api文件夹下建立user.js,然后引入request.js


import request from '@/utils/request'

//个人信息
export const info = data => request.post('/v1/api/info', data)

在页面中直接使用:


import {info} from '@/api/user.js'

export default {
methods: {
async getUserinfo() {
let info = await info()
console.log('用户信息==', info)
}
}
}

版本切换


很多场景下,需要根据不同的环境去切换不同的请求域名、APPID等字段,这时候就需要通过环境变量来进行区分。下面案例我们就分为三个环境:开发环境(dev)、测试环境(test)、生产环境(prod)。


建立env文件


在项目根目录建立下面三个文件并写入内容(常量名要以VUE开头命名):


.env.dev(开发环境)


VUE_APP_MODE=build
VUE_APP_ID=wxbb53ae105735a06b
VUE_APP_BASE=https://www.baidu.dev.com

.env.test(测试环境)


VUE_APP_MODE=build
VUE_APP_ID=wxbb53ae105735a06c
VUE_APP_BASE=https://www.baidu.test.com

.env.prod(生产环境)


VUE_APP_MODE=wxbb53ae105735a06d
VUE_APP_ID=prod
VUE_APP_BASE=https://www.baidu.prod.com

修改package.json文件


"scripts": {
"dev:mp-weixin": "cross-env UNI_PLATFORM=mp-weixin vue-cli-service uni-build --watch --mode dev",
"build:mp-weixin": "cross-env UNI_PLATFORM=mp-weixin vue-cli-service uni-build --watch --mode prod"
},

然后执行


npm run dev:mp-weixin

/src/pages/index/index.vue下,打印:


onLoad() {
console.log(process.env.VUE_APP_MODE, '====VUE_APP_BASE')
console.log(process.env.VUE_APP_BASE, '====VUE_APP_BASE')
},

此时输出结果就是


dev ====VUE_APP_BASE
https://www.baidu.dev.com ====VUE_APP_BASE

动态修改appid


如果同一套代码,需要打包生成多个小程序,就需要动态修改appid了;文章开头说过appid在/src/manifest.json文件中配置,但json文件又不能直接写变量,这时候就可以参考官方 提出的解决方案:建立vue.config.js文件,具体操作如下。


在根目录下建立vue.config.js文件写入以下内容:


// 读取 manifest.json ,修改后重新写入
const fs = require('fs')

const manifestPath = './src/manifest.json'
let Manifest = fs.readFileSync(manifestPath, { encoding: 'utf-8' })
function replaceManifest(path, value) {
const arr = path.split('.')
const len = arr.length
const lastItem = arr[len - 1]

let i = 0
let ManifestArr = Manifest.split(/\n/)

for (let index = 0; index < ManifestArr.length; index++) {
const item = ManifestArr[index]
if (new RegExp(`"${arr[i]}"`).test(item)) ++i
if (i === len) {
const hasComma = /,/.test(item)
ManifestArr[index] = item.replace(
new RegExp(`"${lastItem}"[\\s\\S]*:[\\s\\S]*`),
`"${lastItem}": ${value}${hasComma ? ',' : ''}`
)
break
}
}

Manifest = ManifestArr.join('\n')
}
// 读取环境变量内容
replaceManifest('mp-weixin.appid', `"${process.env.VUE_APP_ID}"`)

fs.writeFileSync(manifestPath, Manifest, {
flag: 'w'
})

结尾


关于uni-app项目的起步工作就到这里了,后面有机会写一套完整的uni搭建电商小程序项目,记得关注。代码已经提交到github,如果对你有帮助,记得点个star!


作者:陇锦
来源:juejin.cn/post/7259589417736847416
收起阅读 »

vscode+vite+ts助你高效开发uni-app项目

前言 最近在基于uni-app开发小程序,由于公司使用的是 HBuilder创建的项目,每次都需要打开HBuilderX当运行工具,开发体验真是难受至极。打算使用vscode + vite + ts创建一套模版,脱离 HBuilder 为什么不喜欢HBuild...
继续阅读 »

前言


最近在基于uni-app开发小程序,由于公司使用的是 HBuilder创建的项目,每次都需要打开HBuilderX当运行工具,开发体验真是难受至极。打算使用vscode + vite + ts创建一套模版,脱离 HBuilder


为什么不喜欢HBuilderX呢?



  1. 超级难用的git管理全局搜索,谁用谁知道

  2. 界面风格,代码样式,格式化,插件生态相比vscode都太差了

  3. 习惯了vscode开发


Snipaste_2023-09-05_21-53-02.png



点击查看 github



cli创建uni-app 项目


1、 创建 Vue3/Vite 工程


# npx degit https://github.com/dcloudio/uni-preset-vue.git#分支名称 自定义项目名称

# 创建以 javascript 开发的工程
npx degit dcloudio/uni-preset-vue#vite uni-starter

# 创建以 typescript 开发的工程
npx degit dcloudio/uni-preset-vue#vite-ts uni-starter



  • degit 可以帮助你从任意 git 仓库中克隆纯净的项目,忽略整个仓库的 git 历史记录。

  • 可以使用 npm install -g degit 命令全局安装



2、进入工程目录


cd uni-starter

3、更新 uni-app依赖版本


npx @dcloudio/uvm@latest

4、安装依赖


推荐一个好用的包管理器 antfu/ni


ni 或 pnpm install 或 bun install

5、运行


# 运行到 h5   
npm run dev:h5
# 运行到 app
npm run dev:app
# 运行到 微信小程序
npm run dev:mp-weixin

6、打包


# 打包到 h5   
npm run build:h5
# 打包到 app
npm run build:app
# 打包到 微信小程序
npm run build:mp-weixin

dcloudio 官方更多模版地址


自动引入



使用了自动引入就无需写下面的 import {xx} from @dcloudio/uni-app/vue。


如果不喜欢此方式可忽略



每个页面使用vue api或者uniapp api都需要引入,个人感觉有些麻烦


import { shallowRef,computed,watch } from 'vue';
import { onLoad,onShow } from "@dcloudio/uni-app";

1、 下载自动引入插件 pnpm add unplugin-auto-import -D


2、vite.config.ts 配置如下:


import { defineConfig } from "vite";
import uni from "@dcloudio/vite-plugin-uni";
// 引入自动导入插件
import AutoImport from 'unplugin-auto-import/vite'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [
uni(),
// 配置自动导入 vue相关函数, uni-app相关函数。ref, reactive,onLoad等
AutoImport({
imports: ['vue','uni-app'],
dts: './typings/auto-imports.d.ts',
}),
],
});

3、tsconfig.json include新增如下类型文件配置


"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue",
// unplugin-auto-import/vite自动引入的类型声明文件
"typings/**/*.d.ts",
"typings/**/*.ts"
]


注意: Option 'importsNotUsedAsValues' is deprecated and will stop functioning in TypeScript 5.5. Specify compilerOption '"ignoreDeprecations": "5.0"' to silence this error. Use 'verbatimModuleSyntax' instead


翻译一下: 选项“importsNotUsedAsValues”已弃用,并将停止在TypeScript 5.5中运行。指定compilerOption“”ignoreDeprecations“:”5.0“”以消除此错误。 请改用“verbatimModuleSyntax”。


如果出现此警告⚠️可添加如下配置



Snipaste_2023-08-22_23-20-42.png


eslint自动格式化



为了使用方便,这里直接使用 antfu大佬的插件了,有需要的配置自行再添加到rules里面。


注意: 这个插件可能更适合web端,antfu基本是不写小程序的,如果有特殊需要或者想更适合小程序版本格式化可以自行配置或者网上找一些格式化方案,这类文章还是比较多的。



使用 eslint + @antfu/eslint-config点击查看使用


1、 安装插件


pnpm add -D eslint @antfu/eslint-config

2、新建.eslintrc.cjs


module.exports = {
root: true,
env: {
browser: true,
node: true,
es6: true,
},
// https://github.com/antfu/eslint-config
extends: '@antfu',
rules: {
// your custom rules...
'vue/html-self-closing': ['error', {
html: { normal: 'never', void: 'always' },
}],
'no-console': 'off', // 禁用对 console 的报错检查
// "@typescript-eslint/quotes": ["error", "double"], // 强制使用双引号
'@typescript-eslint/semi': ['error', 'always'], // 强制使用行位分号
},
};


3、新建.vscode/settings.json


{
// 禁用 prettier,使用 eslint 的代码格式化
"prettier.enable": false,
// 保存时自动格式化
"editor.formatOnSave": false,
// 保存时自动修复
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.organizeImports": false
},
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml"
]
}

4、此时打开App.vue 查看已经检查出规范了,只要保存就会自动格式化


eslint-format.gif


5、提交代码时自动对暂存区代码进行格式化操作


pnpm add -D lint-staged simple-git-hooks

// package.json
"scripts": {
+ "prepare": "pnpx simple-git-hooks",
}
+"simple-git-hooks": {
+ "pre-commit": "pnpm lint-staged"
+},
+"lint-staged": {
+ "*": "eslint --fix"
+}


"prepare": "pnpx simple-git-hooks": 在执行npm install命令之后执行的脚本,用于初始化simple-git-hooks配置



editorConfig 规范



项目根目录添加.editorConfig文件,统一不同编辑器的编码风格和规范。


vscode需要安装插件EditorConfig for VS Code获取支持



# @see: http://editorconfig.org

root = true

[*] # 表示所有文件适用
charset = utf-8 # 设置文件字符编码为 utf-8
indent_style = space # 缩进风格为 空格(tab | space)
indent_size = 2 # 缩进大小为 2
end_of_line = lf # 换行符为 lf (crlf | lf | cr)
insert_final_newline = true # 在文件末尾插入一个新行
trim_trailing_whitespace = true # 去除行尾空格

[*.md] # 表示所有 .md 文件适用
insert_final_newline = false # 在文件末尾不插入一个新行
trim_trailing_whitespace = false # 不去除行尾空格


安装组件库


成套的全端兼容ui库包括:



  • uni-ui:官方组件库,兼容性好、组件封装性好、功能强大,而且还有大佬编写的ts类型。目前正在使用的组件库

  • uview-plus:uview-plus3.0是基于uView2.x修改的vue3版本。

  • uViewUI:组件丰富、文档清晰,支持nvue

  • colorUI css库:颜值很高,css库而非组件

  • 图鸟UI:高颜值UI库

  • 图鸟UI vue3版:高颜值UI库,vue3+ts版组件,值得尝试

  • first UI:分开源版和商业版,虽然组件很全、功能强大,但是大多数组件都是需要购买的商业版才能用


1、安装组件


pnpm add @dcloudio/uni-ui -S
pnpm add sass -D

2、配置easycom自动引入组件


// pages.json
{
"easycom": {
"autoscan": true,
"custom": {
// uni-ui 规则如下配置
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
}
},
// 其他内容
pages:[
// ...
]
}

3、安装uni-uits类型库


pnpm add -D @uni-helper/uni-ui-types

具体使用方法请查看:uni-ui-types


后续


模版更多内置功能(如网络请求、登录、授权、上传、下载、分享)等更新中...


参考链接:



作者:xkfe
来源:juejin.cn/post/7270830083740450816
收起阅读 »

Kotlin开发者尝试Flutter——错怪了Dart这门语言

前言 我曾经是Java安卓开发者,进入大学后了解并且转向了Kotlin开发安卓,时至今日已经有了一年时间,Kotlin带给我的体验实在是太惊艳了,我深信这就是我最喜欢的语言了。 抱着这种看法,我发现了Flutter+Dart这种抽象的组合,大量的嵌套好像让我在...
继续阅读 »

你的段落文字.png


前言


我曾经是Java安卓开发者,进入大学后了解并且转向了Kotlin开发安卓,时至今日已经有了一年时间,Kotlin带给我的体验实在是太惊艳了,我深信这就是我最喜欢的语言了。


抱着这种看法,我发现了Flutter+Dart这种抽象的组合,大量的嵌套好像让我在写ifelse,这导致了我迟迟没有接触Flutter跨平台框架,当然还有一些其他原因。


其实在之前Flutter的跨平台能力已经惊艳到我了,这次寒假正好有机会让我学习它。


当我试着用它完成业务时,我发现好像也不是那么不可接受,我甚至还有那么点快感,如果你写过安卓Compose那么你会更加觉得如此,因为在UI和业务的关系上它真的太容易绑定了,我不再考虑像XML监听数据变化,只可惜Dart语法仍然在一些地方让我感觉到不太好用,还是得Kotlin来,等等,那我不就是想要Compose吗?


哈哈,不要着急,为什么这个项目是Flutter而不是KMP随后我们再说。


其实我本身没有很严重的技术癖,面对新的事物和技术,一旦有合适的机会我都是愿意试一试,比起框架的选择,我更加享受开发过程,思想转换为代码的那一刻至少我是享受的。


这次选择Flutter开发不意味着我会一直选择和追捧它,更不会放弃安卓原生和KMP的学习,因此也希望阅读这篇文章读者意识到这点,我作为原生开发者学习Flutter是希望扩展技能而不是代替原生,Flutter本身也不是这么想的,它更像是给大家了一个更低的开发门槛,让更多其他领域的创作者完成他们作品的一种媒介。



如果你希望快速了解Kotlin开发者使用Dart的开发体验,那么直接跳过下面两部分,直接阅读#错怪的Dart。



动机


我觉得主要动机由两部分组成吧,一部分是跨平台开发本身是我感兴趣的方向之一,另一边是未来工作可能需要吧,现在来看国内跨平台趋势还是比较明显的。


不过我更希望这次项目是体验移动跨平台开发,而不是真正的深入学习移动跨平台开发。为此,我希望可以找到学习成本和项目质量相平衡的开发方式,很遗憾我没有那么多的精力做到既要还要,这是我必须面临的选择。


面对众多跨平台框架下我还是选择了Flutter,这主要与它的跨桌面端和生态完善有关,毫无疑问,Flutter有许多的成品组件,这让我可以快速轻松的上手跨平台开发


为什么是Flutter


这个项目的主要功能就是播放器,只不过这个播放器比较特殊,后续文章我们会揭晓它。


单就网络音频播放器开发任务而言,假设使用KMP可能没有现成封装好的库来给我用,可能许多开发者考虑没有就造一个,很遗憾,我不太具备这样的能力,我们需要同时对接多个平台的媒体播放,无论开发周期,单就这样的任务对我已经是很难了。


好吧,我想必须承认我很菜,但是事实如此,因此我选择了更加成熟的Flutter,避免我写不出来,哈哈哈哈。


不过我们今天先不谈Flutter,我们看看Dart。


错怪的Dart


对Dart的刻板印象是从我第一次见到Flutter的语法时形成的,第一次见到Dart时我还没有接触Kotlin。


看着有点像Java,还有好多_的名字是什么鬼东西、怎么要写这么多return、为什么有个?、总之就是反人类啊!!!


当我真正尝试去编写Flutter程序时,我发现,嗯,错怪Dart了,特别是因为我了解Kotlin后,Kotlin和Dart也有几分相似之处,这体现在一些语法特性上。


空安全


可空类型在Kotlin上可以说相当不错,在Dart上也可以体验到它,虽然它是类型前置,但是写法倒是一样的在类型后加上"?"即可。


class AudioMediaItem {
String title;
String description;
AudioMediaType type;
String? mediaUrl;
String? bvId;
//省略其他代码.....
}

当我们试图使用AudioMediaItem的对象时,我们就可以像Kotlin那样做,注意mediaUrl现在是可空的。


audioMediaItem?.mediaUrl,如果我们认为这个属性一定有值,那么就可以使用audioMediaItem!.mediaUrl,需要注意的是,dart中是"!"而不是"!!"


如果你希望使用Kotlin的Elvis操作符 ?: ,那么你可以这么做


audioMediaItem?.mediaUrl ?? "default";

对应Kotlin的


audioMediaItem?.mediaUrl ?: "default"

在这方面,dart和Kotlin是非常相似的,因此,你可以非常平滑的迁移这部分的开发体验和理解。


延迟初始化


在Kotlin中,我们可以使用lateinit var定义一个非空延迟初始化的变量,通俗的讲就是定义一个非空类型,但是不给初始值。dart也有对应从关键字,那就是late了。


late String name;

相当于Kotlin的


lateinit var String name

我们知道延迟初始化意味着这个值必定有值,只是我们希望这个值在代码运行过程中产生并且初始化,初始化后再使用该值,否则就会空指针了。


如果你已经熟悉了Kotlin的lateinit,那这里也可以平滑迁移了。


但是在Android Studio 2023.1.1我发现个有意思的事情。


late String? name;

ide没有提示这是错误的,我没试着运行,但是我觉得这应该是不合理的。


扩展函数


扩展函数在Kotlin当中可以说相当重要,许多内置函数都是这个特性所带来的。


在Kotlin中,我们通过 被扩展的类名.扩展函数名(){} 这样的写法就实现了一个扩展函数。


fun String.toColorInt(): Int = Color.parseColor(this)

Dart中也存在扩展函数的语法糖!


extension StringExtension on String {
/// 将字符串的首字母大写
String capitalize() {
if (isEmpty) {
return this;
}
return '${this[0].toUpperCase()}${substring(1)}';
}
}

其中,StringExtension只是这个扩展的名字,相当于一个标志,可以随便起,on String则代表扩展String类,那么capitalize 自然就是扩展的方法名了。


将Kotlin的内置函数带入


Kotlin的内置函数实在是太棒了,下面以also和let为例子,模仿了Kotlin的扩展函数,只可惜Dart的lambda不太能像Kotlin那样,还是有一些割裂。


extension AlsoExtension<T> on T {
T also(void Function(T) block) {
block(this);
return this;
}
}

extension LetExtension<T> on T {
R let<R>(R Function(T) block) {
return block(this);
}
}

//用法
String demo = "xada".let((it) => "${it}xadadawdwad");


emm不过因为没办法直接传this,在变量很长或者类型可空时还有点用。


顶层函数


Kotlin中,我们有时候需要在全局使用一些函数,但是不希望写在类里,而是随时随地直接可以调用或者拿到。


注意这些代码不在类里


val json = Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
}

var retrofit = Retrofit.Builder()
.baseUrl("https://api.juejin.cn/")
.addConverterFactory(json.asConverterFactory(MediaType.parse("application/json;charset=utf-8")!!))
.build()


在某个类需要我们就直接写retrofit.xxxx() 就可以了,我们不需要再单独从类中找。


Dart也有这样的功能


final _cookieJar = CookieJar();

final Dio dioClient = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
contentType: Headers.jsonContentType,
persistentConnection: true,
))
..transformer = BackgroundTransformer()
..let((it) {
if (!kIsWeb) {
it.interceptors.add(CookieManager(_cookieJar));
return it;
} else {
return it;
}
});


上面的例子只是写了变量,写函数也是一样的,都可以直接在全局任何的位置调用。


高阶函数


在Kotlin中,高阶函数是特殊的一种函数,这种函数接受了另一个函数作为参数。


我们以Kotlin的forEach函数为例子:



public inline fun <T> Iterable<T>.forEach(action: (T) -> Unit): Unit {
for (element in this) action(element)
}

// 用法
fun main() {
val demoList = listOf("da", "da", "da")
demoList.forEach {
println(it)
}
}

forEach本身扩展了Iterable,但是它的参数非常特殊,我们看看action参数的类型:


(T) -> Unit,这是Kotlin匿名函数的写法,意味着这个函数有一个参数,类型为T泛型,这个参数也没有起名字,所以就只有类型T在。


这种情况,在Java中这种实现一般是接口类,我们需要实例化这个匿名类,假设这个接口只有一个方法,那么就可以转换为lambda的写法。


在Kotlin里我们可以直接写为lambda的形式,要方便很多,由于只有一个参数,那么kotlin默认就叫it了。


OK回顾完Kotlin,我们看看Dart:


void forEach(void action(E element)) {
for (E element in this) action(element);
}

//用法
List<String> demoList = ["da","da","da"];

demoList.forEach((element) {
print(element);
});

其实差别不大,只是我们需要写void当作这个参数的类型,内部写法没有太大差异。


不过,Dart的lambda更加贴近JS,写法基本上是一模一样。


相信如果你已经掌握了Kotlin的高阶函数,那么在Dart尝试也是不错的。


运算符重载


Kotlin当中有个不太常用的东西,叫运算符重载,它在Dart中也有。


public operator fun <T> Collection<T>.plus(elements: Iterable<T>): List<T> {
if (elements is Collection) {
val result = ArrayList<T>(this.size + elements.size)
result.addAll(this)
result.addAll(elements)
return result
} else {
val result = ArrayList<T>(this)
result.addAll(elements)
return result
}
}

//用法
val demoList = listOf("da", "da", "da") + listOf<String>("add")

可以看到kotlin通过operator关键字配合扩展函数实现了这个功能,dart也可以模仿这种手段:


// 模仿
extension ListPlusOperatorExtension<T> on List<T> {
List<T> operator +(List<T> elements) {
List<T> result = this;
addAll(elements);
return result;
}
}

// 用法

List<String> demo1 = ["da","da"];

List<String> demo2 = ["da","d1a"] + demo1;

不过这里的加减乘除就是operator + 了。


总结


可以看得出,Dart也有部分我们在Kotlin中喜欢的特性,如果你已经掌握了Kotlin的基本语法,那么相信Dart对你来说也不是太大问题,你可以平滑的迁移一些在Kotlin中的知识到Dart上去。


起初我是很坑距使用Flutter的,现在看见Dart的特性,我似乎又接受了一些,好吧,对于Flutter开发、布局约束和其他感受我在下一篇文章再分享给大家吧。


最后感谢大家看到这里,还有什么好玩的特性欢迎在下面留言,文章内容有错误请指出。


作者:萌新杰少
来源:juejin.cn/post/7329874214378078245
收起阅读 »

uniapp小程序包过大的问题

uniapp小程序包过大的问题 前言 微信小程序为了优化用户体验,将小程序首次加载的数据限制在了2M以内(推荐1.5M),剩下的数据采取分包(懒加载)的方式进行引用。 一 开启分包subPackages 在manifest.json文件中添加"optimiza...
继续阅读 »

uniapp小程序包过大的问题


前言


微信小程序为了优化用户体验,将小程序首次加载的数据限制在了2M以内(推荐1.5M),剩下的数据采取分包(懒加载)的方式进行引用。


一 开启分包subPackages


manifest.json文件中添加"optimization" : {"subPackages" : true}来开启分包。


1680316261345.png

然后可以在pages.json中添加subPackages来进行分包页面的配置。


当然,uniapp还贴心的为我们提供了便捷的创建方式:


1680316550191.png

二 静态资源优化


小程序中尽量少使用大背景图片,这样会占据大量包资源。微信小程序推荐使用网络图片资源来减少主包资源。因为某种原因,我把图片放进了主包里,但是要进行图片压缩。这里推荐一个图片压缩网站tintpng


image.png

可以看到图片被压缩了百分之62,并且可以批量处理,就很方便。


三 去除冗余代码


这里你以为我会说提升代码质量巴拉巴拉,其实不然。接下来要说的,才是我要写这篇文章的真正原因!!!


如果你使用uniapp开发微信小程序,并直接在微信开发小程序工具中上传,你会发现你的包会离奇的大


image.png

在代码依赖分析中我们可以发现,一个叫common的文件竟有1.3M多,而这个并非是我自己的文件。


image.png

后来发现这应该是uniapp开发时的编译文件,删掉就可以了。


还有一个方法,在uniapp运行到小程序中,时勾选运行时是否开启代码压缩,此时再看代码其实也可以符合要求了:


image.png

四 通过uniapp上传发布


uniapp也提供了通过cli来进行发布小程序的能力:


image.png

这里需要准备的是appId和微信小程序上传接口的key,并且要配置你上传时所在的网络IP,具体方法


结语


OK,当你看到这里,那么恭喜你,又多活了三分钟~respect!!!


作者:FineYoung
来源:juejin.cn/post/7216845797143969850
收起阅读 »

Flutter 首个真正可商用的 JSBridge 框架(完全兼容的 DSBridge for Flutter)

DSBridge for Flutter 在 Android 和 iOS 平台上做过 Hybrid 开发的同学基本都会知道 DSBridge,该框架目前最受欢迎的 JSBridge 框架之一,为了在 Flutter 侧实现原生 Hybrid 的能力,于是我们将...
继续阅读 »

DSBridge for Flutter


在 Android 和 iOS 平台上做过 Hybrid 开发的同学基本都会知道 DSBridge,该框架目前最受欢迎的 JSBridge 框架之一,为了在 Flutter 侧实现原生 Hybrid 的能力,于是我们将其适配到了Flutter 平台。


dsbridge.png



三端易用的现代跨平台 JavaScript bridge,通过它你可以在 JavaScript 和 Flutter 之间同步或异步的调用彼此的函数.



概述


DSBridge for Flutter 完全兼容 Android 和 iOS DSBridge 的 dsbridge.js。不像其他类似的框架无法实现JavaScript 调用 Dart 并同步返回结果,本框架完整支持同步调用和异步调用。dsbridge_flutter 是首个完整实现了 DSBridge 在原 Android 和 iOS 上的所有功能,因此可以实现将原来通过原生实现的 Webview 业务完全迁移到 Flutter 实现,即一套代码实现APP与H5的Hybrid开发。在现有使用了 dsbridge.js 的 Web 项目中无须修改任何代码即可使用 DSBridge for Flutter。


本框架目前支持Android 和 iOS 平台,即将支持纯鸿蒙平台(OpenHarmony & HarmonyOS Next),敬请期待!


DSBridge for Flutter 基于 Flutter官方的 webview_flutter


目前已发布到官方pub.dev:dsbridge_flutter


特性



  1. Android、iOS、JavaScript 三端易用,轻量且强大、安全且健壮。

  2. 同时支持同步调用和异步调用

  3. 支持以类的方式集中统一管理API

  4. 支持API命名空间

  5. 支持调试模式

  6. 支持 API 存在性检测

  7. 支持进度回调:一次调用,多次返回

  8. 支持 JavaScript 关闭页面事件回调

  9. 支持 JavaScript 模态对话框


安装



  1. 添加依赖


    dependencies:
    ...
    dsbridge_flutter: x.y.z



示例


请参考工程目录下的 example 包。运行 example 工程并查看示例交互。


如果要在你自己的项目中使用 dsBridge :


使用



  1. 新建一个Dart类,实现API


    import 'package:dsbridge_flutter/dsbridge_flutter.dart';

    class JsApi extends JavaScriptNamespaceInterface {
    @override
    void register() {
    registerFunction(testSyn);
    registerFunction(testAsyn);
    }

    /// for synchronous invocation
    String testSyn(dynamic msg) {
    return "$msg[syn call]";
    }

    /// for asynchronous invocation
    void testAsyn(dynamic msg, CompletionHandler handler) {
    handler.complete("$msg [ asyn call]");
    }
    }

    所有Dart APIs必须在register函数中使用registerFunction来注册。


  2. 添加API类实例到DWebViewController


    import 'package:dsbridge_flutter/dsbridge_flutter.dart';
    ...
    late final DWebViewController _controller;
    ...
    _controller.addJavaScriptObject(JsApi(), null);


  3. 在 JavaScript 中调用 Dart API ,并注册一个 JavaScript API 供原生调用.



    • 初始化 dsBridge


      //cdn
      //<script src="https://unpkg.com/dsbridge@3.1.3/dist/dsbridge.js"> </script>
      //npm
      //npm install dsbridge@3.1.3
      var dsBridge=require("dsbridge")


    • 调用 Dart API;以及注册一个 JavaScript API 供 Dart 调用.



      //同步调用
      var str=dsBridge.call("testSyn","testSyn");

      //异步调用
      dsBridge.call("testAsyn","testAsyn", function (v) {
      alert(v);
      })

      //注册 JavaScript API
      dsBridge.register('addValue',function(l,r){
      return l+r;
      })




  4. 在 Dart 中调用 JavaScript API


    import 'package:dsbridge_flutter/dsbridge_flutter.dart';
    ...
    late final DWebViewController _controller;
    ...
    _controller.callHandler('addValue', args: [3, 4],
    handler: (retValue) {
    print(retValue.toString());
    });



Dart API 签名


为了兼容Android&iOS,我们约定Dart API 签名,注意,如果API签名不合法,则不会被调用!签名如下:



  1. 同步API.


    any handler(dynamic msg)


    参数必须是 dynamic 类型,并且必须申明(如果不需要参数,申明后不适用即可)。返回值类型没有限制,可以是任意类型。


  2. 异步 API.


    void handler(dynamic arg, CompletionHandler handler)



命名空间


命名空间可以帮助你更好的管理API,这在API数量多的时候非常实用,比如在混合应用中。DSBridge支持你通过命名空间将API分类管理,并且命名空间支持多级的,不同级之间只需用'.' 分隔即可。


调试模式


在调试模式时,发生一些错误时,将会以弹窗形式提示,并且Dart API如果触发异常将不会被自动捕获,因为在调试阶段应该将问题暴露出来。


进度回调


通常情况下,调用一个方法结束后会返回一个结果,是一一对应的。但是有时会遇到一次调用需要多次返回的场景,比如在 JavaScript 中调用端上的一个下载文件功能,端上在下载过程中会多次通知 JavaScript 进度, 然后 JavaScript 将进度信息展示在h5页面上,这是一个典型的一次调用,多次返回的场景,如果使用其它 JavaScript bridge, 你将会发现要实现这个功能会比较麻烦,而 DSBridge 本身支持进度回调,你可以非常简单方便的实现一次调用需要多次返回的场景,下面我们实现一个倒计时的例子:


In Dart


void callProgress(dynamic args, CompletionHandler handler) {
var i = 10;
final timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (i == 0) {
timer.cancel();
handler.complete(0);
} else {
handler.setProgressData(i--);
}
});
}

In JavaScript


dsBridge.call("callProgress", function (value) {
document.getElementById("progress").innerText = value
})

完整的示例代码请参考example工程。


Javascript 对话框


DSBridge 已经实现了 JavaScript 的对话框函数(alert/confirm/prompt),如果你想自定义它们,通过DWebViewController设置相关回调函数即可。DSBridge实现的对话框默认设置是模态的,这会挂起UI线程。


API 列表


Dart API


在 Dart 中我们把实现了供 JavaScript 调用的 API 类的实例称为 Dart API object.


DWebViewController.addJavaScriptObject(JavaScriptNamespaceInterface? object, String? namespace)

Dart API object到DWebViewController,并为它指定一个命名空间。然后,在 JavaScript 中就可以通过bridge.call("namespace.api",...)来调用Dart API object中的原生API了。


如果命名空间是空(null或空字符串), 那么这个添加的Dart API object就没有命名空间。在 JavaScript 通过 bridge.call("api",...)调用。


示例:


In Dart


class JsEchoApi extends JavaScriptNamespaceInterface {
@override
void register() {
registerFunction(syn);
registerFunction(asyn);
}

dynamic syn(dynamic args) {
return args;
}

void asyn(dynamic args, CompletionHandler handler) {
handler.complete(args);
}
}
//namespace is "echo"
controller.addJavaScriptObject(JsEchoApi(), 'echo');

In JavaScript


// call echo.syn
var ret=dsBridge.call("echo.syn",{msg:" I am echoSyn call", tag:1})
alert(JSON.stringify(ret))
// call echo.asyn
dsBridge.call("echo.asyn",{msg:" I am echoAsyn call",tag:2},function (ret) {
alert(JSON.stringify(ret));
})

DWebViewController.removeJavaScriptObject(String namespace)

通过命名空间名称移除相应的Dart API object。


DWebViewController.callHandler(String method, {List? args, OnReturnValue? handler})

调用 JavaScript API。handlerName 为 JavaScript API 的名称,可以包含命名空间;参数以数组传递,args数组中的元素依次对应 JavaScript API的形参; handler 用于接收 JavaScript API 的返回值,注意:handler将在Dart主isolate中被执行


示例:


_controller.callHandler('append', args: ["I", "love", "you"],
handler: (retValue) {
print(retValue.toString());
});
/// call with namespace 'syn', More details to see the Demo project
_controller.callHandler('syn.getInfo', handler: (retValue) {
print(retValue.toString());
});

DWebViewController.javaScriptCloseWindowListener

当 JavaScript 中调用window.close时,DWebViewController 会触发此监听器,你可以自定义回调进行处理。


Example:


controller.javaScriptCloseWindowListener = () {
print('window.close called');
};

DWebViewController.hasJavaScriptMethod(String handlerName, OnReturnValue existCallback)

检测是否存在指定的 JavaScript API,handlerName可以包含命名空间.


示例:


_controller.hasJavaScriptMethod('addValue', (retValue) {
print(retValue.toString());
});

DWebViewController.dispose()

释放资源。在当前页面处于dispose状态时,你应该显式调用它。


JavaScript API


dsBridge

"dsBridge" 在初始化之后可用 .


dsBridge.call(method,[arg,callback])

同步或异步的调用Dart API。


method: Dart API 名称, 可以包含命名空间。


arg:传递给Dart API 的参数。只能传一个,如果需要多个参数时,可以合并成一个json对象参数。


callback(String returnValue): 处理Dart API的返回结果. 可选参数,只有异步调用时才需要提供.


dsBridge.register(methodName|namespace,function|synApiObject)

dsBridge.registerAsyn(methodName|namespace,function|asynApiObject)

注册同步/异步的 JavaScript API. 这两个方法都有两种调用形式:



  1. 注册一个普通的方法,如:


    In JavaScript


    dsBridge.register('addValue',function(l,r){
    return l+r;
    })
    dsBridge.registerAsyn('append',function(arg1,arg2,arg3,responseCallback){
    responseCallback(arg1+" "+arg2+" "+arg3);
    })

    In Dart


    _controller.callHandler('addValue', args: [3, 4],
    handler: (retValue) {
    print(retValue.toString());
    });

    _controller.callHandler('append', args: ["I", "love", "you"],
    handler: (retValue) {
    print(retValue.toString());
    });


  2. 注册一个对象,指定一个命名空间:


    In JavaScript


    //namespace test for synchronous calls
    dsBridge.register("test",{
    tag:"test",
    test1:function(){
    return this.tag+"1"
    },
    test2:function(){
    return this.tag+"2"
    }
    })

    //namespace test1 for asynchronous calls
    dsBridge.registerAsyn("test1",{
    tag:"test1",
    test1:function(responseCallback){
    return responseCallback(this.tag+"1")
    },
    test2:function(responseCallback){
    return responseCallback(this.tag+"2")
    }
    })


    因为 JavaScript 并不支持函数重载,所以不能在同一个 JavaScript 对象中定义同名的同步函数和异步函数



    In Dart


    _controller.callHandler('test.test1',
    handler: (retValue) {
    print(retValue.toString());
    });

    _controller.callHandler('test1.test1',
    handler: (retValue) {
    print(retValue.toString());
    });



dsBridge.hasNativeMethod(handlerName,[type])

检测Dart中是否存在名为handlerName的API, handlerName 可以包含命名空间.


type: 可选参数,["all"|"syn"|"asyn" ], 默认是 "all".


//检测是否存在一个名为'testAsyn'的API(无论同步还是异步)
dsBridge.hasNativeMethod('testAsyn')
//检测test命名空间下是否存在一个’testAsyn’的API
dsBridge.hasNativeMethod('test.testAsyn')
// 检测是否存在一个名为"testSyn"的异步API
dsBridge.hasNativeMethod('testSyn','asyn') //false

最后


如果你喜欢DSBridge for Flutter,欢迎点点star和like,以便更多的人知道它, 谢谢 !


作者:gtbluesky
来源:juejin.cn/post/7328753414724681728
收起阅读 »

从Flutter到Compose,为什么都在推崇声明式UI?

Compose推出之初,就曾引发广泛的讨论,其中一个比较普遍的声音就是——“🤨这跟Flutter也长得太像了吧?!” 这里说的长得像,实际更多指的是UI编码的风格相似,而关于这种风格有一个专门的术语,叫做声明式UI。 对于那些已经习惯了命令式UI的Androi...
继续阅读 »

Compose推出之初,就曾引发广泛的讨论,其中一个比较普遍的声音就是——“🤨这跟Flutter也长得太像了吧?!”


这里说的长得像,实际更多指的是UI编码的风格相似,而关于这种风格有一个专门的术语,叫做声明式UI


对于那些已经习惯了命令式UI的Android或iOS开发人员来说,刚开始确实很难理解什么是声明式UI。就像当初刚踏入编程领域的我们,同样也很难理解面向过程编程面向对象编程的区别一样。


为了帮助这部分原生开发人员完成从命令式UI到声明式UI的思维转变,本文将结合示例代码编写、动画演示以及生活例子类比等形式,详细介绍声明式UI的概念、优点及其应用。


照例,先奉上思维导图一张,方便复习:





命令式UI的特点


既然命令式UI与声明式UI是相对的,那就让我们先来回顾一下,在一个常规的视图更新流程中,如果采用的是命令式UI,会是怎样的一个操作方式。


以Android为例,首先我们都知道,Android所采用的界面布局,是基于View与ViewGr0up对象、以树状结构来进行构建的视图层级。



当我们需要对某个节点的视图进行更新时,通常需要执行以下两个操作步骤:



  1. 使用findViewById()等方法遍历树节点以找到对应的视图。

  2. 通过调用视图对象公开的setter方法更新视图的UI状态


我们以一个最简单的计数器应用为例:



这个应用唯一的逻辑就是“当用户点击"+"号按钮时数字加1”。在传统的Android实现方式下,代码应该是这样子的:


class CounterActivity : AppCompatActivity() {

var count: Int = 0

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_counter)

val countTv = findViewById(R.id.count_tv)
countTv.text = count.toString()

val plusBtn = findViewById

这段代码看起来没有任何难度,也没有明显的问题。但是,假设我们在下一个版本中添加了更多的需求:




  • 当用户点击"+"号按钮,数字加1的同时在下方容器中添加一个方块。

  • 当用户点击"-"号按钮,数字减1的同时在下方容器中移除一个方块。

  • 当数字为0时,下方容器的背景色变为透明。


现在,我们的代码变成了这样:


class CounterActivity : AppCompatActivity() {

var count: Int = 0

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_counter)

// 数字
val countTv = findViewById(R.id.count_tv)
countTv.text = count.toString()

// 方块容器
val blockContainer = findViewById(R.id.block_container)

// "+"号按钮
val plusBtn = findViewById

已经开始看得有点难受了吧?这正是命令式UI的特点,侧重于描述怎么做,我们需要像下达命令一样,手动处理每一项UI的更新,如果UI的复杂度足够高的话,就会引发一系列问题,诸如:



  • 可维护性差:需要编写大量的代码逻辑来处理UI变化,这会使代码变得臃肿、复杂、难以维护。

  • 可复用性差:UI的设计与更新逻辑耦合在一起,导致只能在当前程序使用,难以复用。

  • 健壮性差:UI元素之间的关联度高,每个细微的改动都可能一系列未知的连锁反应。


声明式UI的特点


而同样的功能,假如采用的是声明式UI,则代码应该是这样子的:


class _CounterPageState extends State<CounterPage> {
int _count = 0;

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Column(
children: [
// 数字
Text(
_count.toString(),
style: const TextStyle(fontSize: 48),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
// +"号按钮
ElevatedButton(
onPressed: () {
setState(() {
_count++;
});
},
child: const Text("+")),
// "-"号按钮
ElevatedButton(
onPressed: () {
setState(() {
if (_count == 0) return;
_count--;
});
},
child: const Text("-"))
],
),
Expanded(
// 方块容器
child: Container(
width: 60,
padding: const EdgeInsets.all(10),
color: _count > 0 ? const Color(0xFF6200EE) : Colors.transparent,

child: ListView.separated(
itemCount: _count,
itemBuilder: (BuildContext context, int index) {
// 方块
return Container(width: 40, height: 40, color: Colors.white);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(color: Colors.transparent, height: 10);
},
),
))
],
),
);
}
}


在这样的代码中,我们几乎看不到任何操作UI更新的代码,而这正是声明式UI的特点,它侧重于描述做什么,而不是怎么做,开发者只需要关注UI应该如何呈现,而不需要关心UI的具体实现过程。


开发者要做的,就只是提供不同UI与不同状态之间的映射关系,而无需编写如何在不同UI之间进行切换的代码。


所谓状态,指的是构建用户界面时所需要的数据,例如一个文本框要显示的内容,一个进度条要显示的进度等。Flutter框架允许我们仅描述当前状态,而转换的工作则由框架完成,当我们改变状态时,用户界面将自动重新构建


下面我们将按照通常情况下,用声明式UI实现一个Flutter应用所需要经历的几个步骤,来详细解析前面计数器应用的代码:



  1. 分析应用可能存在的各种状态


根据我们前面对于“状态”的定义,我们可以很容易地得出,在本例中,数字(_count值)本身即为计数器应用的状态,其中还包括数字为0时的一个特殊状态。



  1. 提供每个不同状态所对应要展示的UI


build方法是将状态转换为UI的方法,它可以在任何需要的时候被框架调用。我们通过重写该方法来声明UI的构造:


对于顶部的文本,只需声明每次都使用最新返回的状态(数字)即可:


Text(
_count.toString(),
...
),

对于方块容器,只需声明当_count的值为0时,容器的背景颜色为透明色,否则为特定颜色:


Container(
color: _count > 0 ? const Color(0xFF6200EE) : Colors.transparent,
...
)

对于方块,只需声明返回的方块个数由_count的值决定:


ListView.separated(
itemCount: _count,
itemBuilder: (BuildContext context, int index) {
// 方块
return Container(width: 40, height: 40, color: Colors.white);
},
...
),


  1. 根据用户交互或数据查询结果更改状态


当由于用户的点击数字发生变化,而我们需要刷新页面时,就可以调用setState方法。setState方法将会驱动build方法生成新的UI:


// "+"号按钮
ElevatedButton(
onPressed: () {
setState(() {
_count++;
});
},
child: const Text("+")),
// "-"号按钮
ElevatedButton(
onPressed: () {
setState(() {
if (_count == 0) return;
_count--;
});
},
child: const Text("-"))
],

可以结合动画演示来回顾这整个过程:



最后,用一个公式来总结一下UI、状态与build方法三者的关系,那就是:



以命令式和声明式分别点一杯奶茶


现在,你能了解命令式UI与声明式UI的区别了吗?如果还是有些抽象,我们可以用一个点奶茶的例子来做个比喻:


当我们用命令式UI的思维方式去点一杯奶茶,相当于我们需要告诉制作者,冲一杯奶茶必须按照煮水、冲茶、加牛奶、加糖这几个步骤,一步步来完成,也即我们需要明确每一个步骤,从而使得我们的想法具体而可操作。


而当我们用声明式UI的思维方式去点一杯奶茶,则相当于我们只需要告诉制作者,我需要一杯“温度适中、口感浓郁、有一点点甜味”的奶茶,而不必关心具体的制作步骤和操作细节。


声明式编程的优点


综合以上内容,我们可以得出声明式UI有以下几个优点:



  • 简化开发:开发者只需要维护状态->UI的映射关系,而不需要关注具体的实现细节,大量的UI实现逻辑被转移到了框架中。

  • 可维护性强:通过函数式编程的方式构建和组合UI组件,使代码更加简洁、清晰、易懂,便于维护。

  • 可复用性强:将UI的设计和实现分离开来,使得同样的UI组件可以在不同的应用程序中使用,提高了代码的可复用性。


总结与展望


总而言之,声明式UI是一种更加高层次、更加抽象的编程方式,其最大的优点在于能极大地简化现有的开发模式,因此在现代应用程序中得到广泛的应用,随着更多框架的采用与更多开发者的加入,声明式UI必将继续发展壮大,成为以后构建用户界面的首选方式。


作者:星际码仔
来源:juejin.cn/post/7212622837063811109
收起阅读 »

在微信小程序里运行完整的 Flutter,我们是怎么做到的?

背景 小程序是一种全新的业务形态,特别是微信小程序,既结合了 Web 动态化特性,又拥有 Native 丰富的设备能力支持。 在微信这个宿主上,小程序不仅有稳定的分发渠道,更拥有完善的生命周期、数据、AI 能力支持。 在该微信上开发小程序,一般使用以下两种方法...
继续阅读 »

背景


小程序是一种全新的业务形态,特别是微信小程序,既结合了 Web 动态化特性,又拥有 Native 丰富的设备能力支持。


在微信这个宿主上,小程序不仅有稳定的分发渠道,更拥有完善的生命周期、数据、AI 能力支持。


在该微信上开发小程序,一般使用以下两种方法:



  • JavaScript + WXML + WCSS

  • Taro + React + JavaScript


本文要介绍的是使用 Flutter Framework 开发小程序的方法,以及该方法背后的技术原理。


技术挑战


尽管 Flutter 官方已经提供 Flutter Web 实现,Flutter Web 本身就是基于 dart2js 运行的,微信小程序可以运行 JavaScript,在原理上跑起 Flutter Web 是没有问题的。


但仍然存在以下技术挑战:



  • 微信小程序没有 W3C 标准的 JavaScript 对象,Flutter Web 不能直接运行。

  • 微信小程序也没有 DOM 实现,Flutter Web HTML Renderer 不能直接渲染。

  • 微信小程序对包大小的限制十分严格,主包不能超过 2M,而 Flutter Web 所编译的 main.dart.js 初始体积就有 1.3 M,必须有合理的分包机制才能上传。


我们在 MPFlutter 1.x 版本中,针对上述问题已有一定的探索,1.x 版本的解决方法如下:



  • 使用微信开源的 kbone 库,模拟 W3C 实现,并通过模拟的 DOM 对象渲染出符合 WXML 要求的视图树。

  • 通过 Shadow Element Tree 的方式,使用 JSON 在 Dart 与 JavaScript 上下文同步视图树。

  • Fork Flutter Framework,并对其进行外科手术式的裁剪,使 main.dart.js 初始体积降低到 600K。


MPFlutter 1.x 方案已经良好的运行了两年,也收到了开发者非常多的反馈,开发者常诟病于裁剪后的 Flutter Framework 不兼容 Flutter 生态上的插件,同时 material 库也无法使用,需要从头开始编写 UI。


在 MPFlutter 2.0 版本,我们重新思考在小程序上运行 Flutter 的最佳方式,并在最终使用 CanvasKit Renderer 解决以上全部问题。


技术方案


Summary


通过裁剪 Skia 生成符合微信小程序分包要求的 CanvasKit,使用 Flutter Web + W3C BOM + WebGL Canvas 跑通渲染流程。


技术选型


在介绍技术选型前,需要先介绍 Flutter Web 的两种 Renderer。


HTML Renderer


原理是 Flutter Framework 通过 dart:js 库调用 Document 对象,并基于此将各种 RenderObject 转换为对应的 Element + CSS 添加到 DOM 树中。


该方案优点在于兼容性很好,几乎没有额外的依赖;缺点是性能不佳,并且渲染内容一致性难以与 Native Flutter 对齐。


CanvasKit Renderer


原理是通过 WebGL + Skia 渲染界面,该渲染方式与 Native Flutter 是完全一致的。


该方案优点在于渲染性能非常好,一致性与 Native Flutter 几乎没有差别;缺点是内存占用大,且需要从远端加载字体。


MPFlutter 2.0 选型


我们在 1.x 版本中用的是 HTML Renderer,通过 kbone 运行的 DOM 模拟层存在很多的问题,最令人诟病的是数据更新后界面刷新慢。当然问题的并不在于 kbone,而是 MPFlutter 1.x 本身对于 Element Tree 的序列化、反序列化的处理存在天然的缺陷,尽管已经通过 Dirty 和 Diff 等手段优化。


在 2.x 版本中,我们直接抛弃 HTML Renderer 的想法,使用 CanvasKit Renderer。


使用 CanvasKit Renderer 有这几个大前提:



  • 微信小程序已支持 WebAssembly 并支持 Brotli 压缩;

  • 微信小程序 Canvas 的性能相比最初的版本有质的提升,并支持 WebGL;

  • 微信小程序全部分包限制放宽到 20M,足够使用。


Skia 裁剪


Skia 是 Google 开源的 2D 渲染库,凭借良好的跨设备能力,优秀的性能表现,在 Google 多个产品中被使用,包括 Chrome / Flutter / Android / Fuchsia 都有 Skia 的身影。


Skia 屏蔽了不同设备、平台的具体实现,对外统一以标准的 RenderObject、RenderCommand 开放。


Skia 其中一个 Render Target 是 WebGL,也就是 CanvasKit。


然而 Flutter Web 默认使用的 CanvasKit 足有 6M 之大,即使使用 Brotli 压缩后仍然不符合小程序分包要求。


我们可以通过指定编译选项的方式裁剪 CanvasKit 尺寸,以下是 MPFlutter 使用的 build 配置:


./modules/canvaskit/compile.sh release no_skottie no_sksl_trace no_alias_font no_effects_deserialization no_encode_jpeg no_encode_png no_encode_webp legacy_draw_vertices no_embedded_font no_woff2

从配置可见,我们去掉了 skottie、image encoder、内置字体等不必要的功能,这些功能我们可以使用微信小程序 API 补充回来。


Brotli 压缩后的 wasm 文件刚好符合 2M 分包要求。


CanvasKit 加载


Skia 构建完成后,会得到两个产物,canvaskit.wasmcanvaskit.js


canvaskit.js 暴露了 wasm 中的各个 c++ 方法调用,同时也提供加载 wasm 的脚手架。


但是 canvaskit.js 的实现默认是 Web 的,我们需要将其中的 fetch 以及 WebAssembly 替换为微信小程序对应的实现。


这里提供一个使用 Skia 绘制红色矩形的微信小程序工程,有兴趣的同学可以下载到本地研究。


mpflutter.feishu.cn/wiki/LWhrw3…


Flutter Web 在微信中运行


要使 Flutter Web 在微信中运行,最大难点在于 Flutter Web 要求的 Web API 如何补充完整。


特别是 Document 、Window、Navigator 这些类,这些类我已经在 GitHub 上开源了,感兴趣的可以逐个文件阅读。


github.com/mpflutter/m…


这里举一个 window 的文件节选段落讲解:


export class FlutterMiniProgramMockWindow {
// screens
get devicePixelRatio() {
return wxSystemInfo.pixelRatio;
}

get innerWidth() {
return wxSystemInfo.windowWidth;
}

get innerHeight() {
return wxSystemInfo.windowHeight;
}

// webs
navigator = {
appVersion: "",
platform: "",
userAgent: "",
vendor: "",
language: "zh",
};

// 还有更多。。。
}

Flutter Web 在运行过程中,会通过 window.innerWidth / window.innerHeight 获取当前窗口宽高,以便下一步创建合适大小的画布用于渲染。


在微信小程序中,我们需要使用 wx.getSystemInfoSync() 获取对应宽高,并在 MockWindow 中返回给 Flutter。


关于 BOM 的文件,就不详细展开,都是一些胶水代码。


而 Flutter 的 main.dart.js 也需要有一些改造才可以跑在小程序上,主要的改造是通过 export main.dart.js 中的 main 函数,使其适配 CommonJS 可暴露给 Page 调用。


字体的加载


CanvasKit 最大的问题在于字体加载,目前来看是无法复用系统本身的字体的。


我们的做法是通过裁剪 NotoSansSC 字体,只包含常用的 9000+ 汉字,内置于小程序包中优先加载它。


这样有一个好处,小程序不需要强制从 gstatic 下载字体,省流省加载时间。


后续,我们还会研究通过 Canvas 2D 的方式,从本地加载字体。


分包


关于分包,其实是最好做的,因为 Flutter Web 本身就有 defered load 编译能力。


开发者可以轻松地将 main.dart.js 切分成若干个 JS 文件,我们做的就是在 Flutter Web 编译完成后,智能地将这些 JS 文件分配到不同的分包就好了。


资源分包也同理,资源通过 brotli 压缩也可以减少包体积。


总结


整整一套下来,Flutter 已经可以在微信小程序里跑起来了,我们来总结一下做了什么?


我们通过裁剪 Skia 使得 CanvasKit 可以很好地跑在小程序上,通过 BOM 兼容的方法,使得 Flutter Web 可以在微信小程序中找到对应实现,通过字体内置、智能分包的方式很好地解决了微信包体积限制。


该方案目前已经完全跑通,并已可用,同学们可以在 v2.mpflutter.com 文档站了解到更多用法。


如果对方案有任何疑问,也欢迎添加微信交流,感谢大家的关注。


作者:PonyCui
来源:juejin.cn/post/7324923422295670834
收起阅读 »

刷了四百道算法题,我在项目里用过哪几道呢?

大家好,我是老三,今天和大家聊一个话题:项目中用到的力扣算法。 不知道从什么时候起,算法已经成为了互联网面试的标配,在十年前,哪怕如日中天的百度,面试也最多考个冒泡排序。后来,互联网越来越热,涌进来的人越来越多,整个行业越来越内卷的,算法也慢慢成了大小互联网公...
继续阅读 »

大家好,我是老三,今天和大家聊一个话题:项目中用到的力扣算法。


不知道从什么时候起,算法已经成为了互联网面试的标配,在十年前,哪怕如日中天的百度,面试也最多考个冒泡排序。后来,互联网越来越热,涌进来的人越来越多,整个行业越来越内卷的,算法也慢慢成了大小互联网公司面试的标配,力扣现在已经超过3000题了,那么这些题目有多少进入了面试的考察呢?


以最爱考算法的字节跳动为例,看看力扣的企业题库,发现考过的题目已经有1850道——按照平均每道题花20分钟来算,刷完字节题库的算法题需要37000分钟,616.66小时,按每天刷满8小时算,需要77.08天,一周刷五天,需要15.41周,按一个月四周,需要3.85个月。也就是说,在脱产,最理想的状态下,刷完力扣的字节题库,需要差不多4个月时间。


字节题库


那么,我在项目里用过,包括在项目中见过哪些力扣上的算法呢?我目前刷了400多道题,翻来覆去盘点了一下,发现,也就这么几道。


刷题数量


1.版本比较:比较客户端版本


场景


在日常的开发中,我们很多时候可能面临这样的情况,兼容客户端的版本,尤其是Android和iPhone,有些功能是低版本不支持的,或者说有些功能到了高版本就废弃掉。


这时候就需要进行客户端的版本比较,客户端版本号通常是这种格式6.3.40,这是一个字符串,那就肯定不能用数字类型的比较方法,需要自己定义一个比较的工具方法。


某app版本


题目


165. 比较版本号


这个场景对应LeetCode: 165. 比较版本号



  • 题目:165. 比较版本号 (leetcode.cn/problems/co…)

  • 难度:中等

  • 标签:双指针 字符串

  • 描述:


    给你两个版本号 version1version2 ,请你比较它们。


    版本号由一个或多个修订号组成,各修订号由一个 '.' 连接。每个修订号由 多位数字 组成,可能包含 前导零 。每个版本号至少包含一个字符。修订号从左到右编号,下标从 0 开始,最左边的修订号下标为 0 ,下一个修订号下标为 1 ,以此类推。例如,2.5.330.1 都是有效的版本号。


    比较版本号时,请按从左到右的顺序依次比较它们的修订号。比较修订号时,只需比较 忽略任何前导零后的整数值 。也就是说,修订号 1 和修订号 001 相等 。如果版本号没有指定某个下标处的修订号,则该修订号视为 0 。例如,版本 1.0 小于版本 1.1 ,因为它们下标为 0 的修订号相同,而下标为 1 的修订号分别为 010 < 1


    返回规则如下:



    • 如果 *version1* > *version2* 返回 1

    • 如果 *version1* < *version2* 返回 -1

    • 除此之外返回 0


    示例 1:


    输入:version1 = "1.01", version2 = "1.001"
    输出:0
    解释:忽略前导零,"01""001" 都表示相同的整数 "1"

    示例 2:


    输入:version1 = "1.0", version2 = "1.0.0"
    输出:0
    解释:version1 没有指定下标为 2 的修订号,即视为 "0"

    示例 3:


    输入:version1 = "0.1", version2 = "1.1"
    输出:-1
    解释:version1 中下标为 0 的修订号是 "0",version2 中下标为 0 的修订号是 "1"0 < 1,所以 version1 < version2

    提示:



    • 1 <= version1.length, version2.length <= 500

    • version1version2 仅包含数字和 '.'

    • version1version2 都是 有效版本号

    • version1version2 的所有修订号都可以存储在 32 位整数




解法


那么这道题怎么解呢?这道题其实是一道字符串模拟题,就像标签里给出了了双指针,这道题我们可以用双指针+累加来解决。


在这里插入图片描述



  • 两个指针遍历version1version2

  • . 作为分隔符,通过累加获取每个区间代表的数字

  • 比较数字的大小,这种方式正好可以忽略前导0


来看看代码:


class Solution {
   public int compareVersion(String version1, String version2) {
       int m = version1.length();
       int n = version2.length();

       //两个指针
       int p = 0, q = 0;

       while (p < m || q < n) {
           //累加version1区间的数字
           int x = 0;
           while (p < m && version1.charAt(p) != '.') {
               x += x * 10 + (version1.charAt(p) - '0');
               p++;
          }

           //累加version2区间的数字
           int y = 0;
           while (q < n && version2.charAt(q) != '.') {
               y += y * 10 + (version2.charAt(q) - '0');
               q++;
          }

           //判断
           if (x > y) {
               return 1;
          }
           if (x < y) {
               return -1;
          }

           //跳过.
           p++;
           q++;
      }
       //version1等于version2
       return 0;
  }
}


应用


这段代码,直接CV过来,就可以直接当做一个工具类的工具方法来使用:


public class VersionUtil {

   public static Integer compareVersion(String version1, String version2) {
       int m = version1.length();
       int n = version2.length();

       //两个指针
       int p = 0, q = 0;

       while (p < m || q < n) {
           //累加version1区间的数字
           int x = 0;
           while (p < m && version1.charAt(p) != '.') {
               x += x * 10 + (version1.charAt(p) - '0');
               p++;
          }

           //累加version2区间的数字
           int y = 0;
           while (q < n && version2.charAt(q) != '.') {
               y += y * 10 + (version2.charAt(q) - '0');
               q++;
          }

           //判断
           if (x > y) {
               return 1;
          }
           if (x < y) {
               return -1;
          }

           //跳过.
           p++;
           q++;
      }
       //version1等于version2
       return 0;
  }
}


前面老三分享过一个规则引擎:这款轻量级规则引擎,真香!


比较版本号的方法,还可以结合规则引擎来使用:



  • 自定义函数:利用AviatorScript的自定义函数特性,自定义一个版本比较函数


        /**
        * 自定义版本比较函数
        */

       class VersionFunction extends AbstractFunction {
           @Override
           public String getName() {
               return "compareVersion";
          }

           @Override
           public AviatorObject call(Map<String, Object> env, AviatorObject arg1, AviatorObject arg2) {
               // 获取版本
               String version1 = FunctionUtils.getStringValue(arg1, env);
               String version2 = FunctionUtils.getStringValue(arg2, env);
               return new AviatorBigInt(VersionUtil.compareVersion(version1, version2));
          }
      }


  • 注册函数:将自定义的函数注册到AviatorEvaluatorInstance


        /**
        * 注册自定义函数
        */

       @Bean
       public AviatorEvaluatorInstance aviatorEvaluatorInstance() {
           AviatorEvaluatorInstance instance = AviatorEvaluator.getInstance();
           // 默认开启缓存
           instance.setCachedExpressionByDefault(true);
           // 使用LRU缓存,最大值为100个。
           instance.useLRUExpressionCache(100);
           // 注册内置函数,版本比较函数。
           instance.addFunction(new VersionFunction());
           return instance;
      }


  • 代码传递上下文:在业务代码里传入客户端、客户端版本的上下文


        /**
        * @param device 设备
        * @param version 版本
        * @param rule   规则脚本
        * @return 是否过滤
        */

       public boolean filter(String device, String version, String rule) {
           // 执行参数
           Map<String, Object> env = new HashMap<>();
           env.put("device", device);
           env.put("version", version);
           //编译脚本
           Expression expression = aviatorEvaluatorInstance.compile(DigestUtils.md5DigestAsHex(rule.getBytes()), rule, true);
           //执行脚本
           boolean isMatch = (boolean) expression.execute(env);
           return isMatch;
      }


  • 编写脚本:接下来我们就可以编写规则脚本,规则脚本可以放在数据库,也可以放在配置中心,这样就可以灵活改动客户端的版本控制规则


    if(device==bil){
    return false;
    }

    ## 控制Android的版本
    if (device=="Android" && compareVersion(version,"1.38.1")<0){
    return false;
    }

    return true;



2.N叉数层序遍历:翻译商品类型


场景


一个跨境电商网站,现在有这么一个需求:把商品的类型进行国际化翻译。


某电商网站商品类型国际化


商品的类型是什么结构呢?一级类型下面还有子类型,字类型下面还有子类型,我们把结构一画,发现这就是一个N叉树的结构嘛。


商品树


翻译商品类型,要做的事情,就是遍历这棵树,翻译节点上的类型,这不妥妥的BFS或者DFS!


题目


429. N 叉树的层序遍历


这个场景对应LeetCode:429. N 叉树的层序遍历



  • 题目:429. N 叉树的层序遍历(leetcode.cn/problems/n-…)

  • 难度:中等

  • 标签: 广度优先搜索

  • 描述:


    给定一个 N 叉树,返回其节点值的层序遍历。(即从左到右,逐层遍历)。


    树的序列化输入是用层序遍历,每组子节点都由 null 值分隔(参见示例)。


    示例 1:


    img


    输入:root = [1,null,3,2,4,null,5,6]
    输出:[[1],[3,2,4],[5,6]]

    示例 2:


    img


    输入:root = [1,null,2,3,4,5,null,null,6,7,null,8,null,9,10,null,null,11,null,12,null,13,null,null,14]
    输出:[[1],[2,3,4,5],[6,7,8,9,10],[11,12,13],[14]]

    提示:



    • 树的高度不会超过 1000

    • 树的节点总数在 [0, 10^4] 之间




解法


BFS想必很多同学都很熟悉了,DFS的秘诀是,BFS的秘诀是队列


层序遍历的思路是什么呢?


使用队列,把每一层的节点存储进去,一层存储结束之后,我们把队列中的节点再取出来,孩子节点不为空,就把孩子节点放进去队列里,循环往复。


N叉树层序遍历示意图


代码如下:


class Solution {
public List<List<Integer>> levelOrder(Node root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) {
return result;
}

//创建队列并存储根节点
Deque<Node> queue = new LinkedList<>();
queue.offer(root);

while (!queue.isEmpty()) {
//存储每层结果
List<Integer> level = new ArrayList<>();
int size = queue.size();
for (int i = 0; i < size; i++) {
Node current = queue.poll();
level.add(current.val);
//添加孩子
if (current.children != null) {
for (Node child : current.children) {
queue.offer(child);
}
}
}
//每层遍历结束,添加结果
result.add(level);
}
return result;
}
}

应用


商品类型翻译这个场景下,基本上和这道题目大差不差,不过是两点小区别:



  • 商品类型是一个属性多一些的树节点

  • 翻译过程直接替换类型名称即可,不需要返回值


来看下代码:



  • ProductCategory:商品分类实体


    public class ProductCategory {
    /**
    * 分类id
    */

    private String id;
    /**
    * 分类名称
    */

    private String name;
    /**
    * 分类描述
    */

    private String description;
    /**
    * 子分类
    */

    private List<ProductCategory> children;

    //省略getter、setter

    }




  • translateProductCategory:翻译商品类型方法


       public void translateProductCategory(ProductCategory root) {
    if (root == null) {
    return;
    }

    Deque<ProductCategory> queue = new LinkedList<>();
    queue.offer(root);

    //遍历商品类型,翻译
    while (!queue.isEmpty()) {
    int size = queue.size();
    //遍历当前层
    for (int i = 0; i < size; i++) {
    ProductCategory current = queue.poll();
    //翻译
    String translation = translate(current.getName());
    current.setName(translation);
    //添加孩子
    if (current.getChildren() != null && !current.getChildren().isEmpty()) {
    for (ProductCategory child : current.getChildren()) {
    queue.offer(child);
    }
    }
    }
    }
    }



3.前缀和+二分查找:渠道选择


场景


在电商的交易支付中,我们可以选择一些支付方式,来进行支付,当然,这只是交易的表象。


某电商支付界面


在支付的背后,一种支付方式,可能会有很多种支付渠道,比如Stripe、Adyen、Alipay,涉及到多个渠道,那么就涉及到决策,用户的这笔交易,到底交给哪个渠道呢?


这其实是个路由问题,答案是加权随机,每个渠道有一定的权重,随机落到某个渠道,加权随机有很多种实现方式,其中一种就是前缀和+二分查找。简单说,就是先累积所有元素权重,再使用二分查找来快速查找。


题目


先来看看对应的LeetCode的题目,这里用到了两个算法:前缀和二分查找


704. 二分查找



  • 题目:704. 二分查找(leetcode.cn/problems/bi…)

  • 难度:简单

  • 标签:数组 二分查找

  • 描述:


    给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1


    示例 1:


    输入: nums = [-1,0,3,5,9,12], target = 9
    输出: 4
    解释: 9 出现在 nums 中并且下标为 4

    示例 2:


    输入: nums = [-1,0,3,5,9,12], target = 2
    输出: -1
    解释: 2 不存在 nums 中因此返回 -1

    提示:



    1. 你可以假设 nums 中的所有元素是不重复的。

    2. n 将在 [1, 10000]之间。

    3. nums 的每个元素都将在 [-9999, 9999]之间。




解法


二分查找可以说我们都很熟了。


数组是有序的,定义三个指针,leftrightmid,其中midleftright的中间指针,每次中间指针指向的元素nums[mid]比较和target比较:


二分查找示意图



  • 如果nums[mid]等于target,找到目标

  • 如果nums[mid]小于target,目标元素在(mid,right]区间;

  • 如果nums[mid]大于target,目标元素在[left,mid)区间


代码:


class Solution {
public int search(int[] nums, int target) {
int left=0;
int right=nums.length-1;

while(left<=right){
int mid=left+((right-left)>>1);
if(nums[mid]==target){
return mid;
}else if(nums[mid]<target){
//target在(mid,right]区间,右移
left=mid+1;
}else{
//target在[left,mid)区间,左移
right=mid-1;
}
}
return -1;
}
}

二分查找,有一个需要注意的细节,计算mid的时候:int mid = left + ((right - left) >> 1);,为什么要这么写呢?


因为这种写法int mid = (left + right) / 2;,可能会因为left和right数值太大导致内存溢出。同时,使用位运算,也是除以2最高效的写法。


——这里有个彩蛋,后面再说。


303. 区域和检索 - 数组不可变


不像二分查找,在LeetCode上,前缀和没有直接的题目,因为本身前缀和更多是一种思路,一种工具,其中303. 区域和检索 - 数组不可变 是一道典型的前缀和题目。



  • 题目:303. 区域和检索 - 数组不可变(leetcode.cn/problems/ra…)

  • 难度:简单

  • 标签:设计 数组 前缀和

  • 描述:


    给定一个整数数组 nums,处理以下类型的多个查询:



    1. 计算索引 leftright (包含 leftright)之间的 nums 元素的 ,其中 left <= right


    实现 NumArray 类:



    • NumArray(int[] nums) 使用数组 nums 初始化对象

    • int sumRange(int i, int j) 返回数组 nums 中索引 leftright 之间的元素的 总和 ,包含 leftright 两点(也就是 nums[left] + nums[left + 1] + ... + nums[right] )


    示例 1:


    输入:
    ["NumArray", "sumRange", "sumRange", "sumRange"]
    [[[-2, 0, 3, -5, 2, -1]], [0, 2], [2, 5], [0, 5]]
    输出:
    [null, 1, -1, -3]

    解释:
    NumArray numArray = new NumArray([-2, 0, 3, -5, 2, -1]);
    numArray.sumRange(0, 2); // return 1 ((-2) + 0 + 3)
    numArray.sumRange(2, 5); // return -1 (3 + (-5) + 2 + (-1))
    numArray.sumRange(0, 5); // return -3 ((-2) + 0 + 3 + (-5) + 2 + (-1))

    提示:



    • 1 <= nums.length <= 104

    • -105 <= nums[i] <= 105

    • 0 <= i <= j < nums.length

    • 最多调用 104sumRange 方法




解法


这道题,我们如果不用前缀和的话,写起来也很简单:


class NumArray {
private int[] nums;

public NumArray(int[] nums) {
this.nums=nums;
}

public int sumRange(int left, int right) {
int res=0;
for(int i=left;i<=right;i++){
res+=nums[i];
}
return res;
}
}

当然时间复杂度偏高,O(n),那么怎么使用前缀和呢?



  • 构建一个前缀和数组,用来累积 (0……i-1)的和,这样一来,我们就可以直接计算[left,right]之间的累加和


前缀和数组示意图


代码如下:


class NumArray {
private int[] preSum;

public NumArray(int[] nums) {
int n=nums.length;
preSum=new int[n+1];
//计算nums的前缀和
for(int i=0;i<n;i++){
preSum[i+1]=preSum[i]+nums[i];
}
}

//直接算出区间[left,right]的累加和
public int sumRange(int left, int right) {
return preSum[right+1]-preSum[left];
}
}

可以看到,通过前缀和数组,可以直接算出区间[left,right]的累加和,时间复杂度O(1),可以说非常高效了。


应用


了解了前缀和和二分查找之后,回归我们之前的场景,使用前缀和+二分查找来实现加权随机,从而实现对渠道的分流选择。


渠道分流选择



  • 需要根据渠道和权重的配置,生成一个前缀和数组,来累积权重的值,渠道也通过一个数组进行分配映射

  • 用户的支付请求进来的时候,生成一个随机数,二分查找找到随机数载前缀和数组的位置,映射到渠道数组

  • 最后通过渠道数组的映射,找到选中的渠道


代码如下:


/**
* 支付渠道分配器
*/
public class PaymentChannelAllocator {
//渠道数组
private String[] channels;
//前缀和数组
private int[] preSum;
private ThreadLocalRandom random;

/**
* 构造方法
*
* @param channelWeights 渠道分流权重
*/
public PaymentChannelAllocator(HashMap<String, Integer> channelWeights) {
this.random = ThreadLocalRandom.current();
// 初始化channels和preSum数组
channels = new String[channelWeights.size()];
preSum = new int[channelWeights.size()];

// 计算前缀和
int index = 0;
int sum = 0;
for (String channel : channelWeights.keySet()) {
sum += channelWeights.get(channel);
channels[index] = channel;
preSum[index++] = sum;
}
}

/**
* 渠道选择
*/
public String allocate() {
// 生成一个随机数
int rand = random.nextInt(preSum[preSum.length - 1]) + 1;

// 通过二分查找在前缀和数组查找随机数所在的区间
int channelIndex = binarySearch(preSum, rand);
return channels[channelIndex];
}

/**
* 二分查找
*/
private int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;

while (left <= right) {
int mid = left + ((right - left) >> 2);
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
// 当找不到确切匹配时返回大于随机数的最小前缀和的索引
return left;
}
}

测试一下:


    @Test
void allocate() {
HashMap<String, Integer> channels = new HashMap<>();
channels.put("Adyen", 50);
channels.put("Stripe", 30);
channels.put("Alipay", 20);

PaymentChannelAllocator allocator = new PaymentChannelAllocator(channels);

// 模拟100次交易分配
for (int i = 0; i < 100; i++) {
String allocatedChannel = allocator.allocate();
System.out.println("Transaction " + (i + 1) + " allocated to: " + allocatedChannel);
}
}

彩蛋


在这个渠道选择的场景里,还有两个小彩蛋。


二分查找翻车


我前面提到了一个二分查找求mid的写法:


int mid=left+((right-left)>>1);

这个写法机能防止内存溢出,用了位移运算也很高效,但是,这个简单的二分查找写出过问题,直接导致线上cpu飙升,差点给老三吓尿了。


吓惨了


int mid = (right - left) >> 2 + left;

就是这行代码,看出什么问题来了吗?


——它会导致循环结束不了!


为什么呢?因为>>运算的优先级是要低于+的,所以这个运算实际上等于:


int mid = (right - left) >> (2 + left);

在只有两个渠道的时候没有问题,三个的时候就寄了。


当然,最主要原因还是没有充分测试,所以大家知道我在上面为什么特意写了单测吧。


加权随机其它写法


这里用了前缀和+二分查找来实现加权随机,其实加权随机还有一些其它的实现方法,包括别名方法、树状数组、线段树 随机列表扩展 权重累积等等方法,大家感兴趣可以了解一下。


加权随机的实现


印象比较深刻的是,有场面试被问到了怎么实现加权随机,我回答了权重累积前缀和+二分查找,面试官还是不太满意,最后面试官给出了他的答案——随机列表扩展


什么是随机列表扩展呢?简单说,就是创建一个足够大的列表,根据权重,在相应的区间,放入对应的渠道,生成随机数的时候,就可以直接获取对应位置的渠道。


public class WeightedRandomList {
private final List<String> expandedList = new ArrayList<>();
private final Random random = new Random();

public WeightedRandomList(HashMap<String, Integer> weightMap) {
// 填充 expandedList,根据权重重复元素
for (String item : weightMap.keySet()) {
int weight = weightMap.get(item);
for (int i = 0; i < weight; i++) {
expandedList.add(item);
}
}
}

public String getRandomItem() {
// 生成随机索引并返回对应元素
int index = random.nextInt(expandedList.size());
return expandedList.get(index);
}

public static void main(String[] args) {
HashMap<String, Integer> items = new HashMap<>();
items.put("Alipay", 60);
items.put("Adyen", 20);
items.put("Stripe", 10);

WeightedRandomList wrl = new WeightedRandomList(items);

// 演示随机选择
for (int i = 0; i < 10; i++) {
System.out.println(wrl.getRandomItem());
}
}
}

这种实现方式就是典型的空间换时间,空间复杂度O(n),时间复杂度O(1)。优点是时间复杂度低,缺点是空间复杂度高,如果权重总和特别大的时候,就需要一个特别大的列表来存储元素。


当然这种写法还是很巧妙的,适合元素少、权重总和小的场景。


刷题随想


上面就是我在项目里用到过或者见到过的LeetCode算法应用,416:4,不足1%的使用率,还搞出过严重的线上问题。


……


在力扣社区里关于算法有什么的贴子里,有这样的回复:


“最好的结构是数组,最好的算法是遍历”。


“最好的算法思路是暴力。”


……


坦白说,如果不是为了面试,我是绝对不会去刷算法的,上百个小时,用在其他地方,绝对收益会高很多。


从实际应用去找刷LeetCode算法的意义,本身没有太大意义,算法题的最大意义就是面试。


刷了能过,不刷就挂,仅此而已。


这些年互联网行业红利消失,越来越多的算法题,只是内卷的产物而已。


当然,从另外一个角度来看,考察算法,对于普通的打工人,可能是个更公平的方式——学历、背景都很难卷出来,但是算法可以。


我去年面试的真实感受,“没机会”比“面试难”更令人绝望。


写到这,有点难受,刷几道题缓一下!






参考:


[1].leetcode.cn/circle/disc…


[2].36kr.com/p/121243626…


[3].leetcode.cn/circle/disc…


[4].leetcode.cn/circle/disc…







备注:涉及敏感信息,文中的代码都不是真实的投产代码,作者进行了一定的脱敏和演绎。





作者:三分恶
来源:juejin.cn/post/7321271017429712948
收起阅读 »

一个 Kotlin 开发,对于纯函数的思考

什么是纯函数? 纯函数是数学上的一种理想状态,即相同的输入永远会得到相同的输出,而且没有任何可观察的副作用 在数学上函数的定义为 It must work for every possible input value And it has only one ...
继续阅读 »

什么是纯函数?


纯函数是数学上的一种理想状态,即相同的输入永远会得到相同的输出,而且没有任何可观察的副作用


在数学上函数的定义为



  • It must work for every possible input value

  • And it has only one relationship for each input value



即每个在值域内的输入都能得到唯一的输出,它只可能是多对一而不是一对多的关系:



副作用



Wikipedia Side Effect: In computer science, an operation, function or expression is said to have a side effect if it modifies some state variable value(s) outside its local environment, which is to say if it has any observable effect other than its primary effect of returning a value to the invoker of the operation. Example side effects include modifying a non-local variable, modifying a static local variable, modifying a mutable argument passed by reference, performing I/O or calling other functions with side-effects. In the presence of side effects, a program's behaviour may depend on history; that is, the order of evaluation matters. Understanding and debugging a function with side effects requires knowledge about the context and its possible histories.



副作用的形式很多样,一切影响到外部状态、或依赖于外部状态的行为都可以称为副作用。副作用是必须的,因为程序总是不可避免的要与外界交互,如:


更改外部文件、数据库读写、用户 UI 交互、访问其他具有副作用的函数、修改外部变量


这些都可以被视为副作用,而在函数式编程中我们往往希望使副作用最小化,尽量避免副作用,对应的纯函数则是希望彻底消除副作用,因为副作用让纯函数变得不“纯”,只要一个函数还需要依赖外部状态,那么这个函数就无法始终保持同样的输入得到同样的输出。


好处是什么?



You wanted a banana but what you got was a gorilla holding the banana and the entire jungle. — Joe Armstrong, creator of Erlang progamming




  • 可缓存性,由于唯一的输入代表唯一的输出,那么这意味着我们可以在输入不变的情况下直接返回运算过的结果。

  • 高度并行,由于纯函数不依赖外部状态,因此即便在多线程情况下外部怎么变动,纯函数始终能够返回预期的值,纯函数能够达到真正的无锁编程,高度并发。

  • 高度可测性,不需要依赖外部状态,传统的 OOP 测试我们都需要模拟一个真实的环境,比如在 Android 中将 Application 模拟出来,在执行完之后断言状态的改变。而纯函数只需要模拟输入,断言输入,这是如此的简单优雅。

  • 依赖清晰,面相对象编程总需要你将整个环境初始化出来,然后函数再依赖这些状态修改状态,函数往往伴随着大量外部的隐式依赖,而纯函数只依赖输入参数,仅此而已,也仅提供返回值。


更进一步


传统大学老师教的都是 OOP,所以大多数人最开始也不会去学习纯函数的思路,但纯函数是完全不一样的一套编程思路,下面是一个纯函数中实现循环的例子,传统的循环往往是这样的:


int sum(int[] array) {
int sum = 0;
for (int i = 0; i < array.length; i++) {
sum += array[i];
}
return sum;
}

尽管大多数语言也会提供 for...in 之类的语法,如 kotlin:


fun sum(array: IntArray): Int {
var sum = 0
for (i in array) {
sum += i
}
return sum
}

但我们注意到,在上面的例子中均引入了两个可变的变量,sum 和 i,站在 sum += i 的视角,它的外部依赖:i 是一个外部的可变状态,因此这个函数并不“纯”。


但另一方面来说,从整体函数对外的视角来看,其还是很“纯”的,因为对于传入的外部 array,始终有唯一的 int 返回值,那么我们追求完全的“纯”,完全不使用可变量的目的是什么呢?


在纯函数下要实现完全消灭不可变变量,我们可以这么做:


tailrec fun sum(array: IntArray, current: Int = 0, index: Int = 0): Int {
if (index < 0 || index >= array.size) return current
return sum(array, current + array[index], index + 1)
}

我们编写了退出条件,当 index 在不正常的情况下会,意味着没有东西可以加,直接返回 current,即当前已经算好的值;其余情况则直接返回 current 与当前 index 的值的和,再加上 index + 1 之后所有值的 sum。这个例子已经很简单了,但函数式,递归思维不免会让学传统 OOP 的人需要多加思考一下。


当然作为一个 kotlin 开发,我也毫不犹豫的使用了 tailrec 这个 kotlin 语言特性来帮助优化尾递归,否则在遇到相当长的列表的时候,这个函数会抛出 StackOverFlowError。


函数一等公民


许多面向对象语言通常会用 this 显式访问对象的属性或方法,也有一些语言会省掉编写 this,事实上在许多语言编译器的背后实现中,通常也会将“对象成员的调用”变成“额外给成员函数添加一个 this 变量”。


可见发挥重要作用的其实是函数,不如更进一步,函数是一等公民,对象只不过是个结构体;如此,在纯函数中你完全用不到 this,甚至很多情况下都用不到对象。


所谓的一等公民,就是希望函数包含对象,而不是对象包含函数,甚至可以不需要对象(暴论),下面就是一个例子,是一个常见的业务诉求:



  • UserService 接收用户 id,并提供两个函数来获取用户 token 和用户的本地储存

  • ServerService 需要服务器 ip 和 port,提供通过 secret 获取 token 和通过用户 token 获取用户数据两个能力

  • UserData 是一个用户数据类,它能够接收父布局参数来构建 UI 数据用于显示


class UserService(private  val id: Int) {
fun userToken(password: String): UserToken = TODO()
fun localDb(dbPassword: String): LocalDb = TODO()
}

class ServerService(private val ip: String, private val port: Int) {
fun serverToken(serverSecret: String): ServerToken = TODO()
fun getUser(userToken: UserToken): UserData = TODO()
}

class UserData(
val name: String, val avatarUrl: String, val description: String,
) {
fun uiData(parentLayoutParameters: LayoutParameters): UIData = TODO()
}

那么这些变成函数式会怎么样呢?会像下面这样!


typealias UserTokenService = (password: String) -> UserToken
typealias LocalDbService = (dbPassword: String) -> LocalDb

typealias UserService = (id: Int) -> Pair<UserTokenService, LocalDbService>

typealias ServerTokenService = (serverSecret: String) -> ServerToken
typealias ServerUserService = (userToken: UserToken) -> UserDataAbilities
typealias ServerService = (ip: String, port: Int) -> Pair<ServerTokenService, ServerUserService>

typealias UserUIData = (parentLayoutParameters: LayoutParameters) -> UIData
typealias UserDataAbilities = UserUIData

val userService: UserService = { userId: Int ->
val tokenService: UserTokenService = { password: String -> TODO() }
val localDbService: LocalDbService = { dbPassword: String -> TODO() }
tokenService to localDbService
}

val serverService: ServerService = { ip: String, port: Int ->
val tokenService: ServerTokenService = { serverSecret: String -> TODO() }
val userService: ServerUserService = { userToken: String -> TODO() }
tokenService to userService
}

是不是看起来这些东西变得相当的复杂?但实际上真正的代码并没有写多少行,大量的代码都用来定义类型了!这也就是为什么你能看到的大多数展示函数式的例子都是用 js 去实现的,因为 js 的类型系统很弱,这样函数式写起来会很方便。


我这里用 kt 的范例则是写了大量的类型标记代码,因为我本人对显式声明类型有极高的要求,如果愿意,也可以完全将类型隐藏全靠编译器推理,就像下面这样,一切都变得简洁了,写起来和常规的 OOP 并没有太大区别。


val userService = { userId: Int ->
val tokenService = { password: String -> TODO() }
val localDbService = { dbPassword: String -> TODO() }
tokenService to localDbService
}

val serverService = { ip: String, port: Int ->
val tokenService = { serverSecret: String -> TODO() }
val userService = { userToken: String -> TODO() }
tokenService to userService
}

但不同的是,你看上面的代码,完全没有类/结构体的存在,因为变量的存储全部在函数体内储存了!


对于使用处,两种方式的用法事实上也大同小异,但可以看到我们彻底抛弃了类的存在!甚至在 kotlin 的未来版本中,如果这种代码始终在字节码中编译成 invokeDynamic,那么通过这种方式,字节码中甚至都可以避免类的存在!(当然,在 Android DEX 中会被脱糖成静态内部类)


// OOP
val userService = UserService(id = 0)
val serverService = ServerService(ip = "0.0.0.0", port = 114514)
val userToken = userService.userToken(password = "undefined")
val userData = serverService.getUser(userToken)
val uiData = userData.uiData(parentLayoutParameters)

// functional
val (userTokenService, _) = userService(0)
val (_, userDataService) = serverService("0.0.0.0", 114514)
val userToken = userTokenService("undefined")
val userData = userDataService(userToken)
val uiData = userData(parentLayoutParameters)


BTW,这里函数式 argument 没加 name 主要 kt 现在不支持。。。




柯里化


在上面的例子中,其实我们也能看到,类的存在是不必须的,类的本质其实只是预设好了一部分参数的函数,柯里化要解决的问题就是如何更轻松的实现“预设一部分参数”这样的能力。将一个函数柯里化后,允许多参数函数通过多次来进行传入,如 foo(a, b, c, d, e) 能够变成 foo(a, b)(c)(d, e) 这样的连续函数调用


在下面的例子中,我将举一个计算重量的范例:


fun weight(t: Int, kg: Int, g: Int): Int {
return t * 1000_000 + kg * 1000 + g
}

将其柯里化之后:


val weight = { t: Int ->
{ kg: Int ->
{ g: Int ->
t * 1000_000 + kg * 1000 + g
}
}
}

使用处:


// origin
weight(2, 3, 4)
// currying
weight(2)(3)(4)

在这里我们能发现,柯里化其实让实现处变复杂了,不过在 js 中通常会通过 bind 来实现,kt 也有民间大神 github 开源的柯里化库,使用这些能够从一定程度上降低编写柯里化代码的复杂度。


让我们看看 skiplang 语言吧


skiplang.com/


Skiplang 的宗旨就在其网站主页,A programming language to skip the things you have already computed,在纯函数的情况下,意味着得知输入状态,那么输出状态就是唯一确定的,这种情况就非常适合做缓存,如果输入值已经计算过,那么直接可以返回缓存的输出值。


在纯函数的情况下,意味着运算可以做到高度并行,在 skiplang 中,多个异步线程之间不允许共享可变变量,自然也不会出现异步锁等东西,从而保证了异步的绝对安全。


个人思考


纯函数的收益非常诱人,但开发者往往不喜欢使用纯函数,一些常见的原因可能是:



  1. 对性能的担忧:纯函数不允许修改变量,只允许通过 copy 等方式,创建了大量的变量;编译器需要进行激进的尾递归优化。

  2. 开发者意识淡薄:大多数学校出身的开发者只会用老师教的那一套 OOP,想培养 OOP 向函数式的转变,通常会让很多开发者感到困难,从而认为传统 OOP 简单,也是主流,没必要学新的。


尽管我对纯函数也非常的心动,但是我不是激进的纯函数派,我在日常工作中对其部分认同,具体到 kotlin 编程中,我通常坚持的理念是:



  1. 可以使用类,也可以在类中定义函数,但不允许使用可变成员。

  2. 可以使用可变的 local variable(但不推荐),但不允许在多线程之间共享

  3. 同种副作用,单一数据源。


参考



个人主页原文:一个 Kotlin 开发,对于纯函数的思考


作者:zsqw123
来源:juejin.cn/post/7321049383571046409
收起阅读 »