Flutter デスクトップ開発 - プロジェクト エンジニアリング フレームワークの構築

この記事を通じて、次のことを学びます。

  1. 完全で本番環境に対応した Flutter プロジェクト アーキテクチャ。
  2. mvvm 状態管理ライブラリ GetX のファミリー バケット エクスペリエンス。

⚠️この記事は、レアアース ナゲット テクノロジー コミュニティの最初の署名記事です。14 日以内は転載禁止です。14 日以降は無断で転載禁止です。侵害は調査する必要があります。

序文

本コラムの前回の記事では、デスクトップアプリの画面実装と可定制的窗口化适配了多种分辨率ウィジェット「スマートアイランド」の実装を行いました。これまでの記事はインフラストラクチャの構築に相当しましたが、この記事では、状態管理ライブラリに基づいて、実稼働および開発に導入できる成熟した完全なプロジェクト アーキテクチャをGetX構築しますこれは、その後デスクトップ アプリケーションの開発を続けるための基礎でもあります。

建設原理

プロジェクト フレームワークの構築は完全に GetX ライブラリに基づいています。以前に GetX の長所と短所を分析しましたが、私たちのオープンソース プロジェクトにとって、GetX の「ファミリー バケット」ライブラリは完璧です同時に、モバイル開発とは異なる Windows 開発プロセスにおけるいくつかのヒントも提供します。

GetXファミリーバケットの構築

GetX がファミリー バケットである理由は、MVVM の状態管理だけでなく、国際化、ルーティング設定、ネットワーク リクエストなども満たすことができるためです。個人的なテストでは非常に便利で信頼性があります。GetX

1. 国際化

GetX は、アプリケーションのトップレベルのエントリを提供しますGetMaterialApp。このコントロールは Flutter をカプセル化しますMaterialApp。必要なのは、GetX によって与えられたルールに従って多言語設定を渡すことだけです。構成も非常に簡単で、クラスの get で宣言されたマップ オブジェクトを指定するだけです。マップのキーは言語コードと国と地域で構成されておりシステムロケールの変更などのイベントに対処する必要はありません。

import 'package:get/get.dart';

class Internationalization extends Translations {
  @override
  Map<String, Map<String, String>> get keys => {
    'en_US': {
      'appName': 'Flutter Windows',
      'hello':'Hello World!'
    },
    'zh_CN': {
      'appName': 'Flutter桌面应用',
      'hello':'你好,世界!'
    },
    'zh_HK': {
      'appName': 'Flutter桌面應用',
      'hello':'你好,世界!'
    },
  };
}
复制代码

2. ルーティング設定

如果没有使用GetX,路由管理很大情况是使用Fluro,大量的define、setting、handle真的配置的很枯燥。在GetX中,你只需要配置路由名称和对应的Widget即可。

class RouteConfig {
  /// home模块
  static const String home = "/home/homePage";

  /// 我的模块
  static const String mine = "/mine/myPage";

  static final List<GetPage> getPages = [
    GetPage(name: home, page: () => HomePage()),
    GetPage(name: mine, page: () => MinePage()),
  ];
}
复制代码

至于参数,可以直接像web端的url一样,使用?、&传递。
同时GetX也提供了路由跳转的方式,相比Flutter Navigator2提供的api,GetX的路由跳转明显更加方便,可以脱离context进行跳转,我们可以在VM层随意处理路由,这点真的很爽。

// 跳转到我的页面
Get.toNamed('${RouteConfig.mine}?userId=123&userName=karl');

// 我的页面接收参数
String? userName = Get.parameters['userName'];
复制代码

3. GetX状态管理

状态管理才是GetX的重头戏,GetX中实现的Obx机制,能非常轻量级的帮我们定点刷新。Obx是通过创建定向的Stream,来局部setState的。而且作者还提供了ide的插件,我们来创建一个GetX的页面。
画像.png 通过插件快捷创建之后我们可以得到:logic、state、view的分层结构,通过logic绑定数据和视图,并且实现数据驱动UI刷新。 画像.png 当然,通过Obx的方式会触发创建较多的Stream,有时使用update()来主动刷新也是可以的。
关于GetX的状态管理,有个细节要提示下:

  • 如果listview.build下的item都有自己的状态管理,那么每个item需要向logic传递自己的tag才能产生各自的Obx stream;
Get.put(SwiperItemLogic(), tag: model.key);
复制代码

GetX相对其他的状态管理,最重点是基于Stream实现了真正的跨组件通信,包括兄弟组件;只需要保证logic层Put一次,其余组件去Find即可直接更新logic的值,实现视图刷新。

4. 网络请求

在网络请求上,GetX的封装其实并没有dio来的好,Get_connect插件集成了REST API请求和GraphQL规范,我们开发过程中其实不会两者都用。虽然GraphQL提高了健壮性,但在定义请求对象的时候,往往会增加一些工作量,特别是对于小项目。

  1. 我们可以先创建一个基础内容提供,完成通用配置;
/// 网络请求基类,配置公共属性
class BaseProvider extends GetConnect {
  @override
  void onInit() {
    super.onInit();
    httpClient.baseUrl = Api.baseUrl;
    // 请求拦截
    httpClient.addRequestModifier<void>((request) {
      request.headers['accept'] = 'application/json';
      request.headers['content-type'] = 'application/json';
      return request;
    });

    // 响应拦截;甚至已经把http status都帮我们区分好了
    httpClient.addResponseModifier((request, response) {
      if (response.isOk) {
        return response;
      } else if (response.unauthorized) {
        // 账户权限失效
      }
      return response;
    });
  }
}
复制代码
  1. 然后按照模块化去配置请求,提高可维护性。
import 'package:get/get.dart';

import 'base_provider.dart';

/// 按照模块去制定网络请求,数据源模块化
class HomeProvider extends BaseProvider {
  // get会带上baseUrl
  Future<Response> getHomeSwiper(int id) => get('home/swiper');
}
复制代码

日志记录

日志我们采用Logger进行记录,桌面端一般使用txt文件格式。以时间命名,天为单位建立日志文件即可。如果有需要,也可以加一些定时清理的逻辑。
我们需要重写下LogOutput的方法,把颜色和表情都去掉,避免编码错误,然后实现下单例。

Logger? logger;

Logger get appLogger => logger ??= Logger(
      filter: CustomerFilter(),
      printer: PrettyPrinter(
          printEmojis: false,
          colors: false,
          methodCount: 0,
          noBoxingByDefault: true),
      output: LogStorage(),
    );

class LogStorage extends LogOutput {
  // 默认的日志文件过期时间,以小时为单位
  static const _logExpiredTime = 72;

  /// 日志文件操作对象
  File? _file;

  /// 日志目录
  String? logDir;

  /// 日志名称
  String? logName;

  LogStorage({this.logDir, this.logName});

  @override
  void destroy() {
    deleteExpiredLogs(_logExpiredTime);
  }

  @override
  void init() async {
    deleteExpiredLogs(_logExpiredTime);
  }

  @override
  void output(OutputEvent event) async {
    _file ??= await createFile(logDir, logName);
    String now = CommonUtils.formatDateTime(DateTime.now());
    String version = packageInfo.version;
    _file!.writeAsStringSync('>>>> $version  $now [${event.level.name}]\n',
        mode: FileMode.writeOnlyAppend);

    for (var line in event.lines) {
      _file!.writeAsStringSync('${line.toString()}\n',
          mode: FileMode.writeOnlyAppend);
      debugPrint(line);
    }
  }

  Future<File> createFile(String? logDir, String? logName) async {
    logDir = logDir;
    logName = logName;
    if (logDir == null) {
      Directory documentsDirectory = await getApplicationSupportDirectory();
      logDir =
          "${documentsDirectory.path}${Platform.pathSeparator}${Constants.logDir}";
    }
    logName ??=
        "${CommonUtils.formatDateTime(DateTime.now(), format: 'yyyy-MM-dd')}.txt";

    String path = '$logDir${Platform.pathSeparator}$logName';
    debugPrint('>>>>日志存储路径:$path');
    File file = File(path);
    if (!file.existsSync()) {
      file = await File(path).create(recursive: true);
    }
    return file;
  }
复制代码

吐司提示

吐司用的还是fluttertoast的方式。但是windows的实现比较不一样,在windows上的实现toast提示只能显示在应用窗体内。

static FToast fToast = FToast().init(Get.overlayContext!);

static void showToast(String text, {int? timeInSeconds}) {
  // 桌面版必须使用带context的FToast
  if (Platform.isWindows || Platform.isMacOS) {
    cancelToastForDesktop();
    fToast.showToast(
      toastDuration: Duration(seconds: timeInSeconds ?? 3),
      gravity: ToastGravity.BOTTOM,
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(25.0),
          color: const Color(0xff323334),
        ),
        child: Text(
          text,
          style: const TextStyle(
            fontSize: 16,
            color: Colors.white,
          ),
        ),
      ),
    );
  } else {
    cancelToast();
    Fluttertoast.showToast(
      msg: text,
      gravity: ToastGravity.BOTTOM,
      timeInSecForIosWeb: timeInSeconds ?? 3,
      backgroundColor: const Color(0xff323334),
      textColor: Colors.white,
      fontSize: 16,
    );
  }
}
复制代码

一些的小技巧

代码注入,更简洁的实现单例和构造引用

在开发过程中,我还会使用get_itinjectable来生成自动单例、工厂构造函数等类。好处是让代码更为简洁可靠,便于维护。下面举个萌友上报的例子,初始配置只需要在create中写入即可,然后业务方调用只需要使用GetIt.get<YouMengReport>().report()上报就行了。这就是一个非常完整的单例,使用维护都很方便。

/// 声明单例,并且自动初始化
@singleton(signalsReady: true)
class YouMengReport {
  /// 声明工厂构造函数,自动初始化的时候会自动自行create方法
  @factoryMethod
  create() {
    // 这里可以做一些初始化工作
  }
  report() {}
}
复制代码

json生成器

由于不支持反射,导致Flutter的json解析一直为人诟病。因此使用json_serializable会是一个不错的选择,其原理是通过AOP注解,帮我们生成json编码和解析。通过插件Json2json_serializable可以帮我们自动生成dart文件,如下图: 画像.png

其他

アプリケーションのウィンドウ処理、シングルトン、ウィンドウ効果の相互作用など、Windows プロジェクト フレームワークにも必要な詳細が数多くあり、その保守性を向上させることも非常に重要です。詳細については、このコラムの以前の記事「Flutter デスクトップの実践 」を参照してください。

おすすめ

転載: juejin.im/post/7159958059006033950