一、引言:Flutter 多端开发的核心痛点
Flutter 的核心优势是 "一次编写、多端运行",但实际开发中,很多开发者仅停留在 "能用" 层面:要么直接使用原生组件堆砌业务,导致不同页面组件重复造轮子;要么忽略移动端、Web、桌面端的交互 / 布局差异,出现 "移动端适配完美,Web 端错位,桌面端交互怪异" 的问题。
自定义组件封装是解决上述问题的核心 ------ 优秀的自定义组件既能提升代码复用率(减少 80% 以上的重复代码),又能通过统一的适配逻辑,让一套代码在多端呈现原生级体验。本文将从 "通用基础组件→复杂业务组件→多端适配" 层层递进,结合可运行代码,讲解 Flutter 自定义组件封装的核心原则、实战技巧及多端适配的关键细节,帮助你打造高复用、跨端兼容的 Flutter 组件库。
二、自定义组件封装的核心原则
在动手封装前,先明确 4 个核心原则,避免封装的组件 "能用但难用":
- 单一职责:一个组件只负责一个核心功能(如通用按钮仅处理点击、样式、交互,不耦合业务逻辑);
- 可配置化:通过参数暴露核心样式 / 行为,支持外部自定义(如按钮的颜色、尺寸、点击回调);
- 多端兼容:底层适配多端差异,上层对外提供统一 API(如 Web 端按钮 hover 效果、桌面端鼠标点击反馈);
- 易扩展:通过继承 / 组合模式,支持基于基础组件快速扩展业务变体(如从通用按钮扩展出 "主按钮""次要按钮""危险按钮");
- 鲁棒性:添加参数校验、默认值,避免外部传参异常导致崩溃。
三、实战 1:基础通用组件封装(高复用率核心)
基础通用组件是组件库的基石,以下封装 3 个高频使用的基础组件,覆盖按钮、图片、列表场景,且天然支持多端适配。
3.1 通用按钮组件(MultiPlatformButton)
封装目标:支持自定义尺寸、样式、状态(禁用 / 加载),自动适配 Web 端 hover、桌面端点击反馈、移动端触摸效果。
代码实现
Dart
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
/// 通用多端适配按钮
class MultiPlatformButton extends StatefulWidget {
/// 按钮文本
final String text;
/// 点击回调
final VoidCallback? onTap;
/// 按钮类型(主按钮/次要/危险)
final ButtonType type;
/// 是否禁用
final bool disabled;
/// 是否显示加载状态
final bool loading;
/// 按钮尺寸(大/中/小)
final ButtonSize size;
/// 自定义背景色(优先级高于type)
final Color? bgColor;
/// 自定义文本色(优先级高于type)
final Color? textColor;
/// 圆角大小
final double? borderRadius;
const MultiPlatformButton({
super.key,
required this.text,
this.onTap,
this.type = ButtonType.primary,
this.disabled = false,
this.loading = false,
this.size = ButtonSize.medium,
this.bgColor,
this.textColor,
this.borderRadius,
});
@override
State<MultiPlatformButton> createState() => _MultiPlatformButtonState();
}
class _MultiPlatformButtonState extends State<MultiPlatformButton> {
/// 记录是否hover(仅Web/桌面端生效)
bool _isHover = false;
/// 根据按钮类型获取默认颜色
Color _getBgColor() {
if (widget.bgColor != null) return widget.bgColor!;
if (widget.disabled) return Colors.grey[300]!;
switch (widget.type) {
case ButtonType.primary:
return Colors.blueAccent;
case ButtonType.secondary:
return Colors.grey[200]!;
case ButtonType.danger:
return Colors.redAccent;
}
}
/// 根据按钮类型获取默认文本颜色
Color _getTextColor() {
if (widget.textColor != null) return widget.textColor!;
if (widget.disabled) return Colors.grey[600]!;
switch (widget.type) {
case ButtonType.primary:
return Colors.white;
case ButtonType.secondary:
return Colors.black87;
case ButtonType.danger:
return Colors.white;
}
}
/// 获取按钮尺寸
Size _getButtonSize() {
switch (widget.size) {
case ButtonSize.large:
return const Size(double.infinity, 56);
case ButtonSize.medium:
return const Size(double.infinity, 48);
case ButtonSize.small:
return const Size(double.infinity, 40);
}
}
@override
Widget build(BuildContext context) {
final buttonSize = _getButtonSize();
final bgColor = _getBgColor();
final textColor = _getTextColor();
final borderRadius = widget.borderRadius ?? 8.0;
// 多端交互适配:Web/桌面端添加hover,移动端添加水波纹
return MouseRegion(
onEnter: (_) => setState(() => _isHover = true),
onExit: (_) => setState(() => _isHover = false),
child: GestureDetector(
onTap: widget.disabled || widget.loading ? null : widget.onTap,
// 桌面端/移动端点击反馈
behavior: HitTestBehavior.opaque,
child: Container(
width: buttonSize.width,
height: buttonSize.height,
decoration: BoxDecoration(
color: _isHover && !widget.disabled ? bgColor.withOpacity(0.9) : bgColor,
borderRadius: BorderRadius.circular(borderRadius),
// 桌面端添加阴影提升质感
boxShadow: kIsWeb || defaultTargetPlatform.isDesktop
? [
BoxShadow(
color: Colors.black12,
blurRadius: _isHover ? 6 : 2,
offset: _isHover ? const Offset(0, 2) : const Offset(0, 1),
)
]
: null,
),
child: Center(
child: widget.loading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: textColor,
),
)
: Text(
widget.text,
style: TextStyle(
color: textColor,
fontSize: widget.size == ButtonSize.large ? 16 : (widget.size == ButtonSize.small ? 14 : 15),
fontWeight: FontWeight.w500,
),
),
),
),
),
);
}
}
/// 按钮类型枚举
enum ButtonType {
primary, // 主按钮
secondary, // 次要按钮
danger, // 危险按钮
}
/// 按钮尺寸枚举
enum ButtonSize {
large,
medium,
small,
}
// 扩展:判断平台是否为桌面端
extension TargetPlatformExt on TargetPlatform {
bool get isDesktop => [TargetPlatform.windows, TargetPlatform.macOS, TargetPlatform.linux].contains(this);
}
使用示例
Dart
// 页面中使用通用按钮
class ButtonDemoPage extends StatelessWidget {
const ButtonDemoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('通用按钮示例')),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Column(
children: [
// 主按钮
const MultiPlatformButton(
text: '主按钮(默认)',
type: ButtonType.primary,
size: ButtonSize.medium,
),
const SizedBox(height: 12),
// 次要按钮
MultiPlatformButton(
text: '次要按钮(可点击)',
type: ButtonType.secondary,
onTap: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('次要按钮被点击')),
),
),
const SizedBox(height: 12),
// 危险按钮(加载状态)
const MultiPlatformButton(
text: '危险按钮(加载中)',
type: ButtonType.danger,
loading: true,
),
const SizedBox(height: 12),
// 禁用按钮
const MultiPlatformButton(
text: '禁用按钮',
type: ButtonType.primary,
disabled: true,
),
const SizedBox(height: 12),
// 自定义样式按钮
MultiPlatformButton(
text: '自定义颜色',
bgColor: Colors.purple,
textColor: Colors.white,
borderRadius: 20,
size: ButtonSize.large,
onTap: () => print('自定义按钮点击'),
),
],
),
),
);
}
}
多端适配效果
- 移动端:点击时有触摸反馈,无额外阴影;
- Web 端:鼠标 hover 时背景变暗、阴影放大,点击无额外反馈;
- 桌面端(Windows/macOS):hover 效果 + 阴影质感,点击有轻量反馈;
- 所有端:禁用 / 加载状态统一逻辑,样式一致。
3.2 带状态的网络图片组件(StatefulNetworkImage)
封装目标:支持占位图、错误图、加载动画、点击预览,自动适配 Web 端图片跨域、桌面端高清渲染、移动端缓存。
代码实现(需依赖 cached_network_image)
Dart
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/// 带状态的网络图片组件
class StatefulNetworkImage extends StatelessWidget {
/// 图片URL
final String imageUrl;
/// 宽度
final double width;
/// 高度
final double height;
/// 填充模式
final BoxFit fit;
/// 圆角
final double borderRadius;
/// 是否可点击预览
final bool enablePreview;
/// 占位图(默认灰色骨架)
final Widget? placeholder;
/// 错误占位图
final Widget? errorWidget;
const StatefulNetworkImage({
super.key,
required this.imageUrl,
required this.width,
required this.height,
this.fit = BoxFit.cover,
this.borderRadius = 0,
this.enablePreview = false,
this.placeholder,
this.errorWidget,
});
// 默认占位图(骨架屏)
Widget _defaultPlaceholder() {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(borderRadius),
),
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
// 默认错误图
Widget _defaultErrorWidget() {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(borderRadius),
),
child: const Icon(Icons.broken_image, color: Colors.grey, size: 32),
);
}
// 图片预览弹窗(多端适配)
void _previewImage(BuildContext context) {
if (!enablePreview) return;
showDialog(
context: context,
builder: (context) => Dialog(
backgroundColor: Colors.transparent,
child: InteractiveViewer(
// 桌面端/Web端支持缩放,移动端默认支持
panEnabled: true,
scaleEnabled: true,
maxScale: 3,
child: CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.contain,
placeholder: (_, __) => const Center(child: CircularProgressIndicator()),
errorWidget: (_, __, ___) => const Icon(Icons.error, color: Colors.white, size: 48),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final imageWidget = CachedNetworkImage(
imageUrl: imageUrl,
width: width,
height: height,
fit: fit,
placeholder: (_, __) => placeholder ?? _defaultPlaceholder(),
errorWidget: (_, __, ___) => errorWidget ?? _defaultErrorWidget(),
// Web端适配:禁用缓存(按需),解决跨域问题
cacheManager: kIsWeb
? null // Web端使用默认缓存
: CacheManager(
Config(
'image_cache',
maxAgeCacheObject: const Duration(days: 7),
maxNrOfCacheObjects: 200,
),
),
// 桌面端适配:高清渲染
filterQuality: defaultTargetPlatform.isDesktop ? FilterQuality.high : FilterQuality.medium,
);
// 包装圆角
final clippedImage = ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: imageWidget,
);
// 可预览则添加点击事件
return enablePreview
? GestureDetector(
onTap: () => _previewImage(context),
child: clippedImage,
)
: clippedImage;
}
}
依赖配置(pubspec.yaml)
使用示例
Dart
class ImageDemoPage extends StatelessWidget {
const ImageDemoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('网络图片组件示例')),
body: Padding(
padding: const EdgeInsets.all(16),
child: GridView.count(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
children: [
// 普通图片(不可预览)
const StatefulNetworkImage(
imageUrl: 'https://picsum.photos/800/800?random=1',
width: double.infinity,
height: 150,
borderRadius: 8,
),
// 可预览图片
StatefulNetworkImage(
imageUrl: 'https://picsum.photos/800/800?random=2',
width: double.infinity,
height: 150,
borderRadius: 8,
enablePreview: true,
),
// 错误图片(展示默认错误占位)
const StatefulNetworkImage(
imageUrl: 'https://picsum.photos/error',
width: double.infinity,
height: 150,
borderRadius: 8,
),
// 自定义占位图
StatefulNetworkImage(
imageUrl: 'https://picsum.photos/800/800?random=4',
width: double.infinity,
height: 150,
borderRadius: 8,
placeholder: Container(
color: Colors.blue[100],
child: const Center(child: Text('加载中...')),
),
),
],
),
),
);
}
}
3.3 通用下拉刷新列表(RefreshableList)
封装目标:统一下拉刷新、上拉加载更多逻辑,适配多端滚动行为(Web 端滚动条、桌面端鼠标滚轮、移动端回弹)。
代码实现
Dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/// 通用下拉刷新列表组件
/// [T]:列表数据类型
class RefreshableList<T> extends StatefulWidget {
/// 列表数据
final List<T> data;
/// 列表项构建函数
final Widget Function(BuildContext context, T item, int index) itemBuilder;
/// 下拉刷新回调
final Future<void> Function() onRefresh;
/// 上拉加载更多回调
final Future<void> Function()? onLoadMore;
/// 是否还有更多数据
final bool hasMore;
/// 是否正在加载更多
final bool isLoadingMore;
/// 列表空状态Widget
final Widget? emptyWidget;
/// 加载更多失败Widget
final Widget? loadMoreErrorWidget;
const RefreshableList({
super.key,
required this.data,
required this.itemBuilder,
required this.onRefresh,
this.onLoadMore,
this.hasMore = false,
this.isLoadingMore = false,
this.emptyWidget,
this.loadMoreErrorWidget,
});
@override
State<RefreshableList<T>> createState() => _RefreshableListState<T>();
}
class _RefreshableListState<T> extends State<RefreshableList<T>> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
// 监听滚动,触发加载更多
_scrollController.addListener(() {
if (!widget.hasMore || widget.isLoadingMore || widget.onLoadMore == null) return;
// 滚动到底部前200px触发加载
final triggerThreshold = 200.0;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels;
if (currentScroll >= maxScroll - triggerThreshold) {
widget.onLoadMore!();
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
// 空状态Widget
Widget _emptyWidget() {
return widget.emptyWidget ?? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('暂无数据', style: TextStyle(color: Colors.grey, fontSize: 16)),
],
),
);
}
// 加载更多底部Widget
Widget _loadMoreFooter() {
if (!widget.hasMore) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: Text('已加载全部数据', style: TextStyle(color: Colors.grey))),
);
}
if (widget.isLoadingMore) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
// 加载更多失败
return widget.loadMoreErrorWidget ?? GestureDetector(
onTap: widget.onLoadMore,
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text('加载失败,点击重试', style: TextStyle(color: Colors.blue)),
),
),
);
}
@override
Widget build(BuildContext context) {
// 空数据展示
if (widget.data.isEmpty) {
return RefreshIndicator(
onRefresh: widget.onRefresh,
// 移动端回弹效果,Web/桌面端禁用
displacement: kIsWeb || defaultTargetPlatform.isDesktop ? 0 : 40,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), // 保证下拉刷新可用
child: SizedBox(
height: MediaQuery.of(context).size.height - kToolbarHeight - MediaQuery.of(context).padding.top,
child: _emptyWidget(),
),
),
);
}
// 有数据展示
return RefreshIndicator(
onRefresh: widget.onRefresh,
displacement: kIsWeb || defaultTargetPlatform.isDesktop ? 0 : 40,
child: ListView.builder(
controller: _scrollController,
// 多端滚动行为适配
physics: kIsWeb || defaultTargetPlatform.isDesktop
? const ClampingScrollPhysics() // Web/桌面端无回弹
: const BouncingScrollPhysics(), // 移动端回弹
// 显示滚动条(仅Web/桌面端)
scrollbarOrientation: kIsWeb || defaultTargetPlatform.isDesktop ? ScrollbarOrientation.right : null,
itemCount: widget.data.length + 1, // +1 加载更多footer
itemBuilder: (context, index) {
// 加载更多footer
if (index == widget.data.length) {
return _loadMoreFooter();
}
// 列表项
return widget.itemBuilder(context, widget.data[index], index);
},
),
);
}
}
使用示例(模拟数据加载)
Dart
class ListDemoPage extends StatefulWidget {
const ListDemoPage({super.key});
@override
State<ListDemoPage> createState() => _ListDemoPageState();
}
class _ListDemoPageState extends State<ListDemoPage> {
List<String> _listData = [];
bool _hasMore = true;
bool _isLoadingMore = false;
int _page = 1;
final int _pageSize = 10;
@override
void initState() {
super.initState();
_fetchData(isRefresh: true);
}
// 模拟数据请求
Future<void> _fetchData({required bool isRefresh}) async {
if (isRefresh) {
_page = 1;
} else {
if (_isLoadingMore || !_hasMore) return;
setState(() => _isLoadingMore = true);
}
// 模拟网络延迟
await Future.delayed(const Duration(seconds: 1));
// 模拟数据
final newData = List.generate(_pageSize, (index) => '列表项 ${(_page - 1) * _pageSize + index + 1}');
setState(() {
if (isRefresh) {
_listData = newData;
} else {
_listData.addAll(newData);
}
// 模拟只有3页数据
_hasMore = _page < 3;
_isLoadingMore = false;
_page++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('下拉刷新列表示例')),
body: RefreshableList<String>(
data: _listData,
itemBuilder: (context, item, index) {
return ListTile(
title: Text(item),
leading: CircleAvatar(child: Text('${index + 1}')),
);
},
onRefresh: () => _fetchData(isRefresh: true),
onLoadMore: () => _fetchData(isRefresh: false),
hasMore: _hasMore,
isLoadingMore: _isLoadingMore,
// 自定义空状态
emptyWidget: const Center(
child: Text('点击下拉刷新加载数据', style: TextStyle(color: Colors.grey)),
),
),
);
}
}
四、实战 2:复杂业务组件封装(商品卡片 + 登录表单)
基础组件解决通用问题,复杂业务组件则是基于基础组件的组合,聚焦具体业务场景,同时保持可配置性。
4.1 商品卡片组件(ProductCard)
基于通用按钮、网络图片组件封装,适配多端展示(移动端单列、Web / 桌面端多列)。
代码实现
Dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:your_project/components/buttons/multi_platform_button.dart';
import 'package:your_project/components/images/stateful_network_image.dart';
// 商品模型
class Product {
final String id;
final String name;
final String imageUrl;
final double price;
final double originalPrice;
final bool isOnSale;
const Product({
required this.id,
required this.name,
required this.imageUrl,
required this.price,
required this.originalPrice,
this.isOnSale = false,
});
}
// 商品卡片组件
class ProductCard extends StatelessWidget {
final Product product;
final VoidCallback? onAddToCart;
final VoidCallback? onTap;
const ProductCard({
super.key,
required this.product,
this.onAddToCart,
this.onTap,
});
@override
Widget build(BuildContext context) {
// 多端适配卡片宽度:Web/桌面端固定宽度,移动端自适应
final cardWidth = kIsWeb || defaultTargetPlatform.isDesktop
? 240.0
: MediaQuery.of(context).size.width / 2 - 20;
return GestureDetector(
onTap: onTap,
child: Container(
width: cardWidth,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 2,
offset: const Offset(0, 1),
)
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 商品图片(基于StatefulNetworkImage)
StatefulNetworkImage(
imageUrl: product.imageUrl,
width: double.infinity,
height: 120,
borderRadius: 4,
enablePreview: true,
),
const SizedBox(height: 8),
// 商品名称(最多2行)
Text(
product.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
// 价格区域
Row(
children: [
Text(
'¥${product.price.toStringAsFixed(2)}',
style: const TextStyle(
color: Colors.red,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 4),
if (product.originalPrice > product.price)
Text(
'¥${product.originalPrice.toStringAsFixed(2)}',
style: TextStyle(
color: Colors.grey,
fontSize: 12,
decoration: TextDecoration.lineThrough,
),
),
const Spacer(),
if (product.isOnSale)
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(2),
),
child: const Text(
'秒杀',
style: TextStyle(color: Colors.white, fontSize: 10),
),
),
],
),
const SizedBox(height: 8),
// 加入购物车按钮(基于MultiPlatformButton)
MultiPlatformButton(
text: '加入购物车',
type: ButtonType.primary,
size: ButtonSize.small,
onTap: onAddToCart,
borderRadius: 4,
),
],
),
),
);
}
}
使用示例
Dart
class ProductCardDemoPage extends StatelessWidget {
const ProductCardDemoPage({super.key});
// 模拟商品数据
final List<Product> _products = [
const Product(
id: '1',
name: 'Flutter多端开发实战教程(全彩版)',
imageUrl: 'https://picsum.photos/800/800?random=1',
price: 89.9,
originalPrice: 129.9,
isOnSale: true,
),
const Product(
id: '2',
name: '高性能Flutter组件库(封装指南)',
imageUrl: 'https://picsum.photos/800/800?random=2',
price: 69.9,
originalPrice: 99.9,
),
const Product(
id: '3',
name: 'Flutter跨端适配实战(移动端+Web+桌面端)',
imageUrl: 'https://picsum.photos/800/800?random=3',
price: 79.9,
originalPrice: 109.9,
isOnSale: true,
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('商品卡片示例')),
body: Padding(
padding: const EdgeInsets.all(16),
child: kIsWeb || defaultTargetPlatform.isDesktop
? // Web/桌面端:网格布局(3列)
GridView.count(
crossAxisCount: 3,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
children: _products.map((product) {
return ProductCard(
product: product,
onAddToCart: () => ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${product.name} 加入购物车成功')),
),
onTap: () => print('点击商品:${product.name}'),
);
}).toList(),
)
: // 移动端:网格布局(2列)
GridView.count(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
children: _products.map((product) {
return ProductCard(
product: product,
onAddToCart: () => ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${product.name} 加入购物车成功')),
),
onTap: () => print('点击商品:${product.name}'),
);
}).toList(),
),
),
);
}
}
4.2 登录表单组件(LoginForm)
封装表单验证、输入适配(移动端键盘、Web 端回车登录、桌面端焦点管理)。
代码实现
Dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:your_project/components/buttons/multi_platform_button.dart';
class LoginForm extends StatefulWidget {
final VoidCallback? onLoginSuccess;
const LoginForm({super.key, this.onLoginSuccess});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
bool _obscurePassword = true;
// 表单验证
String? _validateUsername(String? value) {
if (value == null || value.isEmpty) {
return '请输入用户名';
}
if (value.length < 3) {
return '用户名长度不少于3位';
}
return null;
}
String? _validatePassword(String? value) {
if (value == null || value.isEmpty) {
return '请输入密码';
}
if (value.length < 6) {
return '密码长度不少于6位';
}
return null;
}
// 登录逻辑
Future<void> _submitForm() async {
if (_formKey.currentState!.validate()) {
setState(() => _isLoading = true);
// 模拟登录请求
await Future.delayed(const Duration(seconds: 1));
setState(() => _isLoading = false);
// 登录成功回调
widget.onLoginSuccess?.call();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('登录成功')),
);
}
}
}
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// 多端适配表单宽度:Web/桌面端固定宽度,移动端自适应
final formWidth = kIsWeb || defaultTargetPlatform.isDesktop
? 400.0
: MediaQuery.of(context).size.width - 32;
return Form(
key: _formKey,
child: SizedBox(
width: formWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 用户名输入框
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: '用户名',
hintText: '请输入用户名',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
validator: _validateUsername,
// Web/桌面端支持回车切换焦点
textInputAction: TextInputAction.next,
// 桌面端适配:焦点样式
style: defaultTargetPlatform.isDesktop
? const TextStyle(fontSize: 16)
: const TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
// 密码输入框
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: '密码',
hintText: '请输入密码',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
border: const OutlineInputBorder(),
),
obscureText: _obscurePassword,
validator: _validatePassword,
// Web/桌面端支持回车登录
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submitForm(),
),
const SizedBox(height: 24),
// 登录按钮(基于MultiPlatformButton)
MultiPlatformButton(
text: '登录',
type: ButtonType.primary,
size: ButtonSize.large,
onTap: _submitForm,
loading: _isLoading,
disabled: _isLoading,
),
],
),
),
);
}
}
使用示例
Dart
class LoginDemoPage extends StatelessWidget {
const LoginDemoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('登录表单示例')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: LoginForm(
onLoginSuccess: () => print('登录成功,跳转到首页'),
),
),
),
);
}
}
五、多端适配核心技巧总结
5.1 布局适配
- 宽度适配:Web / 桌面端使用固定宽度(如 400px/240px),移动端使用屏幕宽度百分比;
- 间距适配:移动端间距更小(12px/16px),Web / 桌面端间距更大(16px/24px);
- 布局方式:移动端优先单列 / 双列网格,Web / 桌面端支持多列网格(3 列 +)。
5.2 交互适配
- 鼠标交互:Web / 桌面端添加 hover 效果、鼠标光标样式,移动端禁用;
- 键盘 / 焦点:Web / 桌面端支持回车切换焦点 / 提交表单,移动端优化键盘弹出 / 收起;
- 滚动行为:移动端启用回弹滚动(BouncingScrollPhysics),Web / 桌面端禁用(ClampingScrollPhysics)。
5.3 样式适配
- 字体大小:桌面端字体稍大(16px+),移动端稍小(14px+);
- 阴影 / 质感:桌面端 / Web 端添加阴影提升质感,移动端简化阴影;
- 圆角 / 边框:多端统一圆角风格,避免极端值(如移动端圆角 8px,桌面端也保持 8px)。
5.4 资源适配
- 图片:Web 端注意跨域问题,移动端启用缓存,桌面端启用高清渲染;
- 图标:使用 Flutter 内置 IconFont,避免图片图标在不同分辨率下模糊。
六、组件测试与复用最佳实践
6.1 组件测试
- 单元测试:测试组件的参数校验、默认值、状态逻辑;
- Widget 测试:验证组件在不同参数 / 状态下的 UI 展示;
- 多端测试:在 Android/iOS/Windows/macOS/Web 端分别测试交互 / 布局。
6.2 组件复用
- 组件分层:基础组件(按钮 / 图片)→ 业务组件(商品卡片 / 登录表单)→ 页面组件;
- 参数标准化:统一参数命名(如 width/height/borderRadius),减少学习成本;
- 文档注释:为组件添加详细的注释,说明参数含义、使用场景、多端适配逻辑;
- 组件库管理:将通用组件抽离为独立 package,供多个项目复用。
七、避坑指南
- 避免过度封装:仅封装复用率≥3 次的组件,避免为单一场景封装组件;
- 避免硬编码:所有尺寸 / 颜色 / 间距通过参数暴露,或使用主题(Theme)管理;
- 避免忽略平台差异:不要假设 "移动端能跑,其他端也能跑",需针对性适配;
- 避免内存泄漏:组件内的 ScrollController/TextEditingController 必须 dispose;
- 避免冗余适配:利用 Flutter 内置的 MediaQuery/TargetPlatform,减少重复判断。
八、总结与进阶方向
本文从 "通用基础组件→复杂业务组件→多端适配" 完整讲解了 Flutter 自定义组件封装的核心逻辑,封装的组件具备 "高复用、多端兼容、易扩展" 的特点,可直接用于实际项目。
进阶学习方向
- 组件主题化:结合 Flutter Theme,实现组件样式的全局配置(如一键切换主题色);
- 组件状态管理:复杂组件结合 Riverpod/Provider 管理内部状态,提升可维护性;
- 组件动画:为组件添加入场 / 交互动画(如按钮点击动画、图片加载动画);
- 组件国际化:支持多语言配置,适配不同地区的展示逻辑;
- 性能优化:为复杂组件添加 RepaintBoundary,减少不必要的重绘。
Flutter 跨端开发的核心是 "统一逻辑,差异化展示"------ 优秀的自定义组件能让你用一套代码,在多端呈现媲美原生的体验,同时大幅提升开发效率。建议将本文的组件封装思路落地到实际项目,逐步构建属于自己的组件库。
附:完整项目结构
扩展阅读
- Flutter 官方多端适配文档:https://docs.flutter.dev/development/platform-integration
- Flutter 组件封装最佳实践:https://docs.flutter.dev/development/ui/widgets/custom
- CachedNetworkImage 文档:https://pub.dev/packages/cached_network_image
作者注:本文所有代码均可基于 Flutter 3.16 + 版本直接运行,建议在不同平台(Android/iOS/Windows/macOS/Web)分别测试多端适配效果。实际项目中,可根据业务需求扩展组件的配置参数,或基于基础组件封装更多业务组件。如果有组件封装 / 多端适配相关的问题,欢迎在评论区交流~
https://openharmonycrossplatform.csdn.net/content
欢迎大家加入[开源鸿蒙跨平台开发者社区](https://openharmonycrossplatform.csdn.net),一起共建开源鸿蒙跨平台生态。