首發原創flutter3+bitsdojo_window+getx客戶端仿微信exe聊天Flutter-WinChat。
flutter3-dart3-winchat 基於flutter3+dart3+getx+bitsdojo_window+file_picker+media_kit等技術開發桌面端仿微信聊天exe實戰專案。實現了聊天訊息、通訊錄、收藏、朋友圈、短影片、我的等頁面模組。
實現技術
- 編輯器:vscode
- 技術框架:flutter3.16.5+dart3.2.3
- 視窗管理:bitsdojo_window: ^0.1.6
- 托盤圖示:system_tray: ^2.0.3
- 路由/狀態管理:get: ^4.6.6
- 本地儲存:get_storage: ^2.1.1
- 圖片預覽外掛:photo_view: ^0.14.0
- 網址預覽:url_launcher: ^6.2.4
- 影片元件:media_kit: ^1.1.10+1
- 檔案選擇器:file_picker: ^6.1.1
目前網上關於flutter3.x開發的桌面端專案並不多,希望有更多的開發者能加入flutter在window/macos客戶端的探索開發。
專案結構
如上圖:flutter構建的專案結構層級。
需要注意的是在開發之前需要自行配置好flutter sdk和dart sdk環境。
https://flutter.dev/
https://www.dartcn.com/
透過 flutter run -d windows 命令,執行到windows上。
主入口main.dart
import 'dart:io'; import 'package:flutter/material.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:media_kit/media_kit.dart'; import 'package:system_tray/system_tray.dart'; import 'utils/index.dart'; // 引入公共樣式 import 'styles/index.dart'; // 引入公共佈局模板 import 'layouts/index.dart'; // 引入路由配置 import 'router/index.dart'; void main() async { // 初始化get_storage儲存類 await GetStorage.init(); // 初始化media_kit影片套件 WidgetsFlutterBinding.ensureInitialized(); MediaKit.ensureInitialized(); initSystemTray(); runApp(const MyApp()); // 初始化bitsdojo_window視窗 doWhenWindowReady(() { appWindow.size = const Size(850, 620); appWindow.minSize = const Size(700, 500); appWindow.alignment = Alignment.center; appWindow.title = 'Flutter3-WinChat'; appWindow.show(); }); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return GetMaterialApp( title: 'FLUTTER3 WINCHAT', debugShowCheckedModeBanner: false, theme: ThemeData( primaryColor: FStyle.primaryColor, useMaterial3: true, // 修正windows端字型粗細不一致 fontFamily: Platform.isWindows ? 'Microsoft YaHei' : null, ), home: const Layout(), // 初始路由 initialRoute: Utils.isLogin() ? '/index' :'/login', // 路由頁面 getPages: routes, onInit: () {}, onReady: () {}, ); } } // 建立系統托盤圖示 Future<void> initSystemTray() async { String trayIco = 'assets/images/tray.ico'; SystemTray systemTray = SystemTray(); // 初始化系統托盤 await systemTray.initSystemTray( title: 'system-tray', iconPath: trayIco, ); // 右鍵選單 final Menu menu = Menu(); await menu.buildFrom([ MenuItemLabel(label: 'show', onClicked: (menuItem) => appWindow.show()), MenuItemLabel(label: 'hide', onClicked: (menuItem) => appWindow.hide()), MenuItemLabel(label: 'close', onClicked: (menuItem) => appWindow.close()), ]); await systemTray.setContextMenu(menu); // 右鍵事件 systemTray.registerSystemTrayEventHandler((eventName) { debugPrint('eventName: $eventName'); if (eventName == kSystemTrayEventClick) { Platform.isWindows ? appWindow.show() : systemTray.popUpContextMenu(); } else if (eventName == kSystemTrayEventRightClick) { Platform.isWindows ? systemTray.popUpContextMenu() : appWindow.show(); } }); }
整個專案採用 bitsdojo_window 外掛進行視窗管理。支援無邊框視窗,視窗尺寸大小,自定義系統操作按鈕(最大化/最小化/關閉)。
https://pub-web.flutter-io.cn/packages/bitsdojo_window
flutter桌面端透過 system_tray 外掛,生成系統托盤圖示。
https://pub-web.flutter-io.cn/packages/system_tray
Flutter路由管理
整個專案採用Getx作為路由和狀態管理。將MaterialApp替換為GetMaterialApp元件。
class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return GetMaterialApp( title: 'FLUTTER3 WINCHAT', debugShowCheckedModeBanner: false, theme: ThemeData( primaryColor: FStyle.primaryColor, useMaterial3: true, ), home: const Layout(), // 初始路由 initialRoute: Utils.isLogin() ? '/index' :'/login', // 路由頁面 getPages: routes, ); } }
新建router/index.dart路由管理檔案。
import 'package:flutter/material.dart'; import 'package:get/get.dart'; // 引入工具類 import '../utils/index.dart'; /* 引入路由頁面 */ import '../views/auth/login.dart'; import '../views/auth/register.dart'; // 首頁 import '../views/index/index.dart'; // 通訊錄 import '../views/contact/index.dart'; import '../views/contact/addfriends.dart'; import '../views/contact/newfriends.dart'; import '../views/contact/uinfo.dart'; // 收藏 import '../views/favor/index.dart'; // 我的 import '../views/my/index.dart'; import '../views/my/setting.dart'; import '../views/my/recharge.dart'; import '../views/my/wallet.dart'; // 朋友圈 import '../views/fzone/index.dart'; import '../views/fzone/publish.dart'; // 短影片 import '../views/fvideo/index.dart'; // 聊天 import '../views/chat/group-chat/chat.dart'; // 路由地址集合 final Map<String, Widget> routeMap = { '/index': const Index(), '/contact': const Contact(), '/addfriends': const AddFriends(), '/newfriends': const NewFriends(), '/uinfo': const Uinfo(), '/favor': const Favor(), '/my': const My(), '/setting': const Setting(), '/recharge': const Recharge(), '/wallet': const Wallet(), '/fzone': const Fzone(), '/publish': const PublishFzone(), '/fvideo': const Fvideo(), '/chat': const Chat(), }; final List<GetPage> patchRoute = routeMap.entries.map((e) => GetPage( name: e.key, // 路由名稱 page: () => e.value, // 路由頁面 transition: Transition.noTransition, // 跳轉路由動畫 middlewares: [AuthMiddleware()], // 路由中介軟體 )).toList(); final List<GetPage> routes = [ GetPage(name: '/login', page: () => const Login()), GetPage(name: '/register', page: () => const Register()), ...patchRoute, ];
Getx提供了middlewares中介軟體進行路由攔截。
// 路由攔截 class AuthMiddleware extends GetMiddleware { @override RouteSettings? redirect(String? route) { return Utils.isLogin() ? null : const RouteSettings(name: '/login'); } }
Flutter3桌面端自定義最大化/最小化/關閉
flutter開發桌面端專案,為了達到桌面視窗高定製化效果,採用了bitsdojo_window外掛。該外掛支援去掉系統導航條,自定義視窗大小、右上角操作按鈕、拖拽視窗等功能。
@override Widget build(BuildContext context){ return Row( children: [ Container( child: widget.leading, ), Visibility( visible: widget.minimizable, child: MouseRegion( cursor: SystemMouseCursors.click, child: SizedBox( width: 32.0, height: 36.0, child: MinimizeWindowButton(colors: buttonColors, onPressed: handleMinimize,), ) ), ), Visibility( visible: widget.maximizable, child: MouseRegion( cursor: SystemMouseCursors.click, child: SizedBox( width: 32.0, height: 36.0, child: isMaximized ? RestoreWindowButton(colors: buttonColors, onPressed: handleMaxRestore,) : MaximizeWindowButton(colors: buttonColors, onPressed: handleMaxRestore,), ), ), ), Visibility( visible: widget.closable, child: MouseRegion( cursor: SystemMouseCursors.click, child: SizedBox( width: 32.0, height: 36.0, child: CloseWindowButton(colors: closeButtonColors, onPressed: handleExit,), ), ), ), Container( child: widget.trailing, ), ], ); }
自定義最大化/最小化/關閉功能。
// 最小化 void handleMinimize() { appWindow.minimize(); } // 設定最大化/恢復 void handleMaxRestore() { appWindow.maximizeOrRestore(); } // 關閉 void handleExit() { showDialog( context: context, builder: (context) { return AlertDialog( content: const Text('是否最小化至托盤,不退出程式?', style: TextStyle(fontSize: 16.0),), backgroundColor: Colors.white, surfaceTintColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(3.0)), elevation: 3.0, actionsPadding: const EdgeInsets.all(15.0), actions: [ TextButton( onPressed: () { Get.back(); appWindow.close(); }, child: const Text('退出', style: TextStyle(color: Colors.red),) ), TextButton( onPressed: () { Get.back(); appWindow.hide(); }, child: const Text('最小化至托盤', style: TextStyle(color: Colors.deepPurple),) ), ], ); } ); }
flutter內建了滑鼠手勢元件MouseRegion。根據需求可以自定義設定不同的滑鼠樣式。
問:bitsdojo_window設定最大化/恢復不能實時監測視窗尺寸變化?
答:大家可以透過flutter內建的WidgetsBindingObserver來監測視窗變化。
class _WinbtnState extends State<Winbtn> with WidgetsBindingObserver { // 是否最大化 bool isMaximized = false; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } // 監聽視窗尺寸變化 @override void didChangeMetrics() { super.didChangeMetrics(); WidgetsBinding.instance.addPostFrameCallback((_) { setState(() { isMaximized = appWindow.isMaximized; }); }); } // ... }
Flutter3公共佈局模板
整體專案佈局參照了微信桌面端介面。分為左側操作欄+側邊欄+右側內容區三大模組。
class Layout extends StatefulWidget { const Layout({ super.key, this.activitybar = const Activitybar(), this.sidebar, this.workbench, this.showSidebar = true, }); final Widget? activitybar; // 左側操作欄 final Widget? sidebar; // 側邊欄 final Widget? workbench; // 右側工作皮膚 final bool showSidebar; // 是否顯示側邊欄 @override State<Layout> createState() => _LayoutState(); }
左側操作欄無點選事件區域支援拖拽視窗。
return Scaffold( backgroundColor: Colors.grey[100], body: Flex( direction: Axis.horizontal, children: [ // 左側操作欄 MoveWindow( child: widget.activitybar, onDoubleTap: () => {}, ), // 側邊欄 Visibility( visible: widget.showSidebar, child: SizedBox( width: 270.0, child: Container( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ Color(0xFFEEEBE7), Color(0xFFEEEEEE) ] ), ), child: widget.sidebar, ), ), ), // 主體容器 Expanded( child: Column( children: [ WindowTitleBarBox( child: Row( children: [ Expanded( child: MoveWindow(), ), // 右上角操作按鈕組 Winbtn( leading: Row( children: [ IconButton(onPressed: () {}, icon: const Icon(Icons.auto_fix_high), iconSize: 14.0,), IconButton( onPressed: () { setState(() { winTopMost = !winTopMost; }); }, tooltip: winTopMost ? '取消置頂' : '置頂', icon: const Icon(Icons.push_pin_outlined), iconSize: 14.0, highlightColor: Colors.transparent, // 點選水波紋顏色 isSelected: winTopMost ? true : false, // 是否選中 style: ButtonStyle( visualDensity: VisualDensity.compact, backgroundColor: MaterialStateProperty.all(winTopMost ? Colors.grey[300] : Colors.transparent), shape: MaterialStatePropertyAll( RoundedRectangleBorder(borderRadius: BorderRadius.circular(0.0)) ), ), ), ], ), ), ], ), ), // 右側工作皮膚 Expanded( child: Container( child: widget.workbench, ), ), ], ), ), ], ), );
左側Tab切換操作欄,使用 NavigationRail 元件實現功能。該元件支援自定義頭部和尾部元件。
@override Widget build(BuildContext context) { return Container( width: 54.0, decoration: const BoxDecoration( color: Color(0xFF2E2E2E), ), child: NavigationRail( backgroundColor: Colors.transparent, labelType: NavigationRailLabelType.none, // all 顯示圖示+標籤 selected 只顯示啟用圖示+標籤 none 不顯示標籤 indicatorColor: Colors.transparent, // 去掉選中橢圓背景 indicatorShape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(0.0), ), unselectedIconTheme: const IconThemeData(color: Color(0xFF979797), size: 24.0), selectedIconTheme: const IconThemeData(color: Color(0xFF07C160), size: 24.0,), unselectedLabelTextStyle: const TextStyle(color: Color(0xFF979797),), selectedLabelTextStyle: const TextStyle(color: Color(0xFF07C160),), // 頭部(影像) leading: GestureDetector( onPanStart: (details) => {}, child: Container( margin: const EdgeInsets.only(top: 30.0, bottom: 10.0), child: InkWell( child: Image.asset('assets/images/avatar/uimg1.jpg', height: 36.0, width: 36.0,), onTapDown: (TapDownDetails details) { cardDX = details.globalPosition.dx; cardDY = details.globalPosition.dy; }, onTap: () { showCardDialog(context); }, ), ), ), // 尾部(連結) trailing: Expanded( child: Container( margin: const EdgeInsets.only(bottom: 10.0), child: GestureDetector( onPanStart: (details) => {}, child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton(icon: Icon(Icons.info_outline, color: Color(0xFF979797), size: 24.0), onPressed:(){showAboutDialog(context);}), PopupMenuButton( icon: const Icon(Icons.menu, color: Color(0xFF979797), size: 24.0,), offset: const Offset(54.0, 0.0), tooltip: '', color: const Color(0xFF353535), surfaceTintColor: Colors.transparent, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0.0)), padding: EdgeInsets.zero, itemBuilder: (BuildContext context) { return <PopupMenuItem>[ popupMenuItem('我的私密空間', 0), popupMenuItem('鎖定', 1), popupMenuItem('意見反饋', 2), popupMenuItem('設定', 3), ]; }, onSelected: (value) { switch(value) { case 0: Get.toNamed('/my'); break; case 3: Get.toNamed('/setting'); break; } }, ), ], ), ), ), ), selectedIndex: tabCur, destinations: [ ...tabNavs ], onDestinationSelected: (index) { setState(() { tabCur = index; if(tabRoute[index] != null && tabRoute[index]?['path'] != null) { Get.toNamed(tabRoute[index]['path']); } }); }, ), ); }
Flutter3朋友圈功能
@override Widget build(BuildContext context) { return Layout( showSidebar: false, workbench: CustomScrollView( slivers: [ SliverAppBar( backgroundColor: const Color(0xFF224E7F), foregroundColor: Colors.white, pinned: true, elevation: 0.0, expandedHeight: 200.0, leading: IconButton(icon: const Icon(Icons.arrow_back,), onPressed: () {Navigator.pop(context);}), flexibleSpace: FlexibleSpaceBar( title: Row( children: <Widget>[ ClipOval(child: Image.asset('assets/images/avatar/uimg1.jpg',height: 36.0,width: 36.0,fit: BoxFit.fill)), const SizedBox(width: 10.0), const Text('Andy', style: TextStyle(fontSize: 14.0)), ], ), titlePadding: const EdgeInsets.fromLTRB(55, 10, 10, 10), background: InkWell( child: Image.asset('assets/images/cover.jpg', fit: BoxFit.cover), onTap: () {changePhotoAlbum(context);}, ), ), actions: <Widget>[ IconButton(icon: const Icon(Icons.favorite_border, size: 18,), onPressed: () {}), IconButton(icon: const Icon(Icons.share, size: 18,), onPressed: () {}), IconButton(icon: const Icon(Icons.add_a_photo, size: 18,), onPressed: () {Get.toNamed('/publish');}), const SizedBox(width: 10.0,), ], ), SliverToBoxAdapter( child: UnconstrainedBox( child: Container( width: MediaQuery.of(context).size.height * 3 / 4, decoration: const BoxDecoration( color: Colors.white, ), child: Column( children: uzoneList.map((item) { return Container( padding: const EdgeInsets.all(15.0), decoration: const BoxDecoration( border: Border(bottom: BorderSide(color: Color(0xFFEEEEEE), width: .5)), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ Image.asset(item['avatar'],height: 35.0,width: 35.0,fit: BoxFit.cover), const SizedBox(width: 10.0), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ Text(item['author'], style: TextStyle(color: Colors.indigo[400])), const SizedBox(height: 2.0), Text(item['content'], style: const TextStyle(color: Colors.black87, fontSize: 15.0)), const SizedBox(height: 10.0), GroupZone(images: item['images']), const SizedBox(height: 10.0), Row( children: <Widget>[ Expanded(child: Text(item['time'], style: const TextStyle(color: Colors.grey, fontSize: 12.0)),), FStyle.iconfont(0xe653, color: Colors.black54, size: 16.0,), ], ) ], ), ), ], ), ); }).toList(), ), ), ), ), ], ), ); }
圖片排列類似微信朋友圈九宮格,支援點選大圖預覽。
Flutter3短影片模組
使用media_kit外掛整合進了短影片功能,支援點選播放/暫停,上下滑動功能。
底部mini時間進度條是自定義元件實現功能效果。
// flutter3短影片模板 Q:282310962 Container( width: MediaQuery.of(context).size.height * 9 / 16, decoration: const BoxDecoration( color: Colors.black, ), child: Stack( children: [ // Swiper垂直滾動區域 PageView( // 自定義滾動行為(支援桌面端滑動、去掉捲軸槽) scrollBehavior: SwiperScrollBehavior().copyWith(scrollbars: false), scrollDirection: Axis.vertical, controller: pageController, onPageChanged: (index) { // 暫停(垂直滑動) controller.player.pause(); }, children: [ Stack( children: [ // 影片區域 Positioned( top: 0, left: 0, right: 0, bottom: 0, child: GestureDetector( child: Stack( children: [ // 短影片外掛 Video( controller: controller, fit: BoxFit.cover, // 無控制條 controls: NoVideoControls, ), // 播放/暫停按鈕 Center( child: IconButton( onPressed: () { controller.player.playOrPause(); }, icon: StreamBuilder( stream: controller.player.stream.playing, builder: (context, playing) { return Visibility( visible: playing.data == false, child: Icon( playing.data == true ? Icons.pause : Icons.play_arrow_rounded, color: Colors.white70, size: 50, ), ); }, ), ), ), ], ), onTap: () { controller.player.playOrPause(); }, ), ), // 右側操作欄 Positioned( bottom: 70.0, right: 10.0, child: Column( children: [ // ... ], ), ), // 底部資訊區域 Positioned( bottom: 30.0, left: 15.0, right: 80.0, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ... ], ), ), // 播放mini進度條 Positioned( bottom: 15.0, left: 15.0, right: 15.0, child: Container( // ... ), ), ], ), Container( color: Colors.black, child: const Center(child: Text('1', style: TextStyle(color: Colors.white, fontSize: 60),),) ), Container( color: Colors.black, child: const Center(child: Text('2', style: TextStyle(color: Colors.white, fontSize: 60),),) ), Container( color: Colors.black, child: const Center(child: Text('3', style: TextStyle(color: Colors.white, fontSize: 60),),) ), ], ), // 固定tab選單 Align( alignment: Alignment.topCenter, child: DefaultTabController( length: 3, child: TabBar( tabs: const [ Tab(text: '推薦'), Tab(text: '關注'), Tab(text: '同城'), ], tabAlignment: TabAlignment.center, overlayColor: MaterialStateProperty.all(Colors.transparent), unselectedLabelColor: Colors.white70, labelColor: const Color(0xff0091ea), indicatorColor: const Color(0xff0091ea), indicatorSize: TabBarIndicatorSize.label, dividerHeight: 0, indicatorPadding: const EdgeInsets.all(5), ), ), ), ], ), ),
Flutter3聊天模組
如上圖:表情彈窗使用showDialog來實現功能。
// 表情彈窗 void showEmojDialog() { updateAnchorOffset(anchorEmojKey); showDialog( context: context, barrierColor: Colors.transparent, // 遮罩透明 builder: (context) { // 解決flutter透過 setState 方法無法更新當前的dialog狀態 // dialog是一個路由頁面,本質跟你當前主頁面是一樣的。在Flutter中它是一個新的路由。所以,你使用當前頁面的 setState 方法當然是沒法更新dialog中內容。 // 如何更新dialog中的內容呢?答案是使用StatefulBuilder。 return StatefulBuilder( builder: (BuildContext context, StateSetter setState) { setEmojState = setState; return Stack( children: [ Positioned( top: anchorDy - (anchorDy - 100) - 15, left: anchorDx - 180, width: 360.0, height: anchorDy - 100, child: Material( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), color: Colors.white, elevation: 1.0, clipBehavior: Clip.hardEdge, child: Column( children: renderEmojWidget(), ), ), ) ], ); }, ); }, ); }
注意:透過 setState 方法無法更新當前的dialog狀態!!!
showDialog本質上是另一個路由頁面,它的性質跟你當前主頁面是一樣的。在Flutter中它是一個新的路由。所以,你使用當前頁面的 setState 方法當然是沒法更新dialog中內容。如何更新dialog中的內容呢?答案是使用StatefulBuilder。
late StateSetter setEmojState;
// 表情Tab切換 void handleEmojTab(index) { var emols = emoJson; for(var i = 0, len = emols.length; i < len; i++) { emols[i]['selected'] = false; } emols[index]['selected'] = true; setEmojState(() { emoJson = emols; }); emojController.jumpTo(0); }
聊天編輯框模組新增了按住說話功能。按住說話、左滑取消、右滑轉文字功能。
由於上一篇文章有過這方面的分享,這裡就不詳細介紹了。
好了,以上就是flutter3.x+dart3開發桌面端仿微信exe聊天應用的一些知識分享,希望能喜歡哈~~💪
最後附上兩個最新例項專案
https://www.cnblogs.com/xiaoyan2017/p/18008370
https://www.cnblogs.com/xiaoyan2017/p/17938517