欢迎大家加入[开源鸿蒙跨平台开发者社区](https://openharmonycrossplatform.csdn.net),一起共建开源鸿蒙跨平台生态。

在 Flutter 开发中,“组件化” 是提升开发效率、保证代码可维护性的核心抓手。原生组件虽能满足基础需求,但实际业务中,我们总会遇到 “按钮要加圆角和渐变”“列表项要统一布局”“输入框要带防抖校验” 等定制化场景。直接在业务页面堆砌样式和逻辑,会导致代码冗余、维护成本飙升。本文将从 “为什么封装”“封装的核心原则” 出发,通过 3 个由浅入深的实战案例,带你掌握 Flutter 自定义组件的封装技巧,从 “重复造轮子” 到 “高效复用组件库”。

一、先想清楚:自定义组件封装的核心价值

在动手编码前,我们先明确封装的底层逻辑,避免为了封装而封装:

  1. 复用性:一套逻辑多处使用,比如电商 App 的商品卡片、社交 App 的评论项;
  2. 可维护性:样式 / 逻辑集中管理,修改一处即可同步所有使用场景;
  3. 可读性:业务页面只关注 “用什么”,而非 “怎么实现”,代码结构更清晰;
  4. 扩展性:预留扩展接口,应对后续需求变更(如按钮新增加载状态);
  5. 性能优化:封装时可针对性做缓存、懒加载等优化,避免重复计算。

二、封装的核心原则:高内聚、低耦合

优秀的自定义组件需遵循 5 个原则,这是后续案例的核心指导思想:

原则 核心说明
单一职责 一个组件只做一件事(如按钮组件只处理点击和样式,不包含业务逻辑)
配置化 通过参数暴露可定制项,核心逻辑内部封装
兼容性 适配不同场景(如按钮支持不同尺寸、颜色、禁用状态)
无侵入 不依赖外部上下文 / 全局状态,可独立使用
可测试 组件逻辑可单独测试,无需依赖业务页面

三、实战案例 1:基础样式封装 —— 渐变按钮

3.1 需求分析

业务中经常需要 “渐变背景 + 圆角 + 点击反馈 + 加载状态” 的按钮,原生ElevatedButton无法直接满足,且每个页面重复写渐变样式会导致代码冗余。

3.2 封装实现

创建widgets/gradient_button.dart

dart

import 'package:flutter/material.dart';

/// 渐变按钮组件
/// 支持自定义渐变颜色、圆角、尺寸、加载状态、点击事件
class GradientButton extends StatefulWidget {
  // 按钮文本
  final String text;
  // 渐变起始色
  final Color startColor;
  // 渐变结束色
  final Color endColor;
  // 按钮宽度(默认占满父容器)
  final double? width;
  // 按钮高度(默认48)
  final double height;
  // 圆角半径(默认8)
  final double borderRadius;
  // 点击回调
  final VoidCallback? onTap;
  // 是否禁用(禁用时无点击反馈,样式置灰)
  final bool disabled;
  // 是否显示加载状态(加载时禁用点击,显示loading)
  final bool loading;
  // 文本样式
  final TextStyle? textStyle;

  // 构造函数:设置默认值,保证易用性
  const GradientButton({
    super.key,
    required this.text,
    this.startColor = Colors.blue,
    this.endColor = Colors.blueAccent,
    this.width,
    this.height = 48,
    this.borderRadius = 8,
    this.onTap,
    this.disabled = false,
    this.loading = false,
    this.textStyle,
  });

  @override
  State<GradientButton> createState() => _GradientButtonState();
}

class _GradientButtonState extends State<GradientButton> {
  // 按钮是否被按下(用于点击反馈)
  bool _isPressed = false;

  @override
  Widget build(BuildContext context) {
    // 最终是否可点击:未禁用 + 未加载
    final bool isClickable = !widget.disabled && !widget.loading;

    // 构建渐变背景
    final gradient = LinearGradient(
      begin: Alignment.centerLeft,
      end: Alignment.centerRight,
      // 禁用/加载时渐变置灰
      colors: isClickable
          ? [widget.startColor, widget.endColor]
          : [Colors.grey.shade300, Colors.grey.shade400],
    );

    // 按钮核心样式
    final boxDecoration = BoxDecoration(
      gradient: gradient,
      borderRadius: BorderRadius.circular(widget.borderRadius),
      // 按下时添加阴影,增强交互反馈
      boxShadow: _isPressed
          ? [
              BoxShadow(
                color: widget.startColor.withOpacity(0.3),
                blurRadius: 8,
                offset: const Offset(2, 2),
              )
            ]
          : null,
    );

    return GestureDetector(
      // 禁用/加载时不响应点击
      onTap: isClickable ? widget.onTap : null,
      // 按下/抬起时更新状态,实现点击反馈
      onTapDown: (_) => isClickable ? setState(() => _isPressed = true) : null,
      onTapUp: (_) => isClickable ? setState(() => _isPressed = false) : null,
      onTapCancel: () => setState(() => _isPressed = false),
      child: Container(
        width: widget.width,
        height: widget.height,
        decoration: boxDecoration,
        alignment: Alignment.center,
        // 按钮内容:加载状态显示Loading,否则显示文本
        child: widget.loading
            ? const SizedBox(
                width: 20,
                height: 20,
                child: CircularProgressIndicator(
                  strokeWidth: 2,
                  valueColor: AlwaysStoppedAnimation(Colors.white),
                ),
              )
            : Text(
                widget.text,
                style: widget.textStyle ??
                    const TextStyle(
                      color: Colors.white,
                      fontSize: 16,
                      fontWeight: FontWeight.w500,
                    ),
              ),
      ),
    );
  }
}

3.3 代码深度解析

  1. 参数设计
    • 必选参数:text(按钮文本),保证组件基础可用性;
    • 可选参数:渐变颜色、尺寸、圆角等,提供定制化能力;
    • 状态参数:disabled(禁用)、loading(加载),覆盖常见交互场景。
  2. 交互反馈
    • 通过GestureDetector监听onTapDown/onTapUp,实现按下时的阴影效果,提升用户体验;
    • 禁用 / 加载状态下,onTap置为null,避免无效点击。
  3. 样式适配
    • 禁用 / 加载时自动将渐变置灰,无需外部额外处理;
    • 文本样式支持外部覆盖,兼顾默认样式和定制需求。

3.4 使用示例

在业务页面中使用封装的按钮:

dart

import 'package:flutter/material.dart';
import 'widgets/gradient_button.dart';

class ButtonDemoPage extends StatefulWidget {
  const ButtonDemoPage({super.key});

  @override
  State<ButtonDemoPage> createState() => _ButtonDemoPageState();
}

class _ButtonDemoPageState extends State<ButtonDemoPage> {
  bool _isLoading = false;

  // 模拟按钮点击逻辑
  void _handleClick() async {
    setState(() => _isLoading = true);
    // 模拟网络请求
    await Future.delayed(const Duration(seconds: 2));
    setState(() => _isLoading = false);
    // 业务逻辑
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('按钮点击成功!')),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('渐变按钮示例')),
      body: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 30),
        child: Column(
          children: [
            // 默认样式按钮
            GradientButton(
              text: '默认渐变按钮',
              onTap: () => ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(content: Text('默认按钮点击')),
              ),
            ),
            const SizedBox(height: 20),
            // 自定义渐变颜色+圆角
            GradientButton(
              text: '自定义渐变',
              startColor: Colors.pink,
              endColor: Colors.purple,
              borderRadius: 20,
              onTap: () => ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(content: Text('自定义渐变按钮点击')),
              ),
            ),
            const SizedBox(height: 20),
            // 加载状态按钮
            GradientButton(
              text: '提交数据',
              startColor: Colors.green,
              endColor: Colors.greenAccent,
              loading: _isLoading,
              onTap: _handleClick,
            ),
            const SizedBox(height: 20),
            // 禁用状态按钮
            GradientButton(
              text: '禁用按钮',
              disabled: true,
              onTap: () {}, // 点击无响应
            ),
          ],
        ),
      ),
    );
  }
}

💡 使用亮点:业务页面只需关注 “按钮文本、点击事件”,无需关心渐变、加载状态、点击反馈的实现,代码量减少 80% 以上。

四、实战案例 2:业务组件封装 —— 商品卡片

4.1 需求分析

电商 App 中商品卡片会在首页、分类页、搜索页重复出现,包含 “图片、标题、价格、销量、收藏按钮” 等元素,且样式统一,适合封装为业务组件。

4.2 封装实现

第一步:定义数据模型

创建models/product_model.dart,标准化商品数据:

dart

/// 商品数据模型
class ProductModel {
  final String id;         // 商品ID
  final String imageUrl;   // 商品图片
  final String title;      // 商品标题
  final double price;      // 商品价格
  final int sales;         // 销量
  final bool isFavorite;   // 是否收藏

  const ProductModel({
    required this.id,
    required this.imageUrl,
    required this.title,
    required this.price,
    required this.sales,
    this.isFavorite = false,
  });
}
第二步:封装商品卡片组件

创建widgets/product_card.dart

dart

import 'package:flutter/material.dart';
import '../models/product_model.dart';

/// 商品卡片组件
class ProductCard extends StatelessWidget {
  final ProductModel product;
  // 收藏按钮点击回调
  final VoidCallback onFavoriteTap;
  // 卡片点击回调
  final VoidCallback onTap;
  // 是否显示销量(可选,适配不同场景)
  final bool showSales;

  const ProductCard({
    super.key,
    required this.product,
    required this.onFavoriteTap,
    required this.onTap,
    this.showSales = true,
  });

  @override
  Widget build(BuildContext context) {
    // 标题最多显示2行,超出省略
    final titleTextStyle = Theme.of(context).textTheme.titleMedium?.copyWith(
          overflow: TextOverflow.ellipsis,
          maxLines: 2,
        );

    // 价格样式
    final priceTextStyle = TextStyle(
      color: Colors.redAccent,
      fontSize: 18,
      fontWeight: FontWeight.bold,
    );

    // 销量样式
    final salesTextStyle = TextStyle(
      color: Colors.grey.shade600,
      fontSize: 12,
    );

    return GestureDetector(
      onTap: onTap,
      child: Container(
        width: 160, // 固定宽度,保证布局统一
        padding: const EdgeInsets.all(8),
        decoration: BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.circular(8),
          boxShadow: [
            BoxShadow(
              color: Colors.grey.shade100,
              blurRadius: 4,
              offset: const Offset(0, 2),
            )
          ],
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // 商品图片区域(带圆角)
            ClipRRect(
              borderRadius: BorderRadius.circular(4),
              child: Image.network(
                product.imageUrl,
                width: double.infinity,
                height: 120,
                fit: BoxFit.cover,
                // 图片加载失败占位
                errorBuilder: (context, error, stackTrace) => Container(
                  width: double.infinity,
                  height: 120,
                  color: Colors.grey.shade100,
                  child: const Icon(Icons.image_not_supported, color: Colors.grey),
                ),
                // 图片加载中占位
                loadingBuilder: (context, child, loadingProgress) {
                  if (loadingProgress == null) return child;
                  return Container(
                    width: double.infinity,
                    height: 120,
                    color: Colors.grey.shade100,
                    child: const Center(child: CircularProgressIndicator(strokeWidth: 1)),
                  );
                },
              ),
            ),
            const SizedBox(height: 8),
            // 商品标题
            Text(product.title, style: titleTextStyle),
            const SizedBox(height: 4),
            // 价格 + 销量
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text('¥${product.price.toStringAsFixed(2)}', style: priceTextStyle),
                if (showSales)
                  Text('销量${product.sales}', style: salesTextStyle),
              ],
            ),
            const SizedBox(height: 8),
            // 收藏按钮
            Align(
              alignment: Alignment.centerRight,
              child: IconButton(
                onPressed: onFavoriteTap,
                icon: Icon(
                  product.isFavorite ? Icons.favorite : Icons.favorite_border,
                  color: product.isFavorite ? Colors.red : Colors.grey,
                  size: 20,
                ),
                padding: EdgeInsets.zero,
                constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

4.3 核心设计亮点

  1. 数据与 UI 解耦:通过ProductModel标准化输入,避免零散参数传递;
  2. 容错处理:图片加载失败 / 加载中提供占位符,避免 UI 崩溃;
  3. 场景适配showSales参数控制是否显示销量,适配不同页面需求;
  4. 样式统一:固定卡片宽度、统一圆角 / 阴影,保证全局样式一致性;
  5. 交互分层:卡片点击(进入详情)、收藏按钮点击(收藏操作)分开回调,职责清晰。

4.4 使用示例

dart

import 'package:flutter/material.dart';
import 'widgets/product_card.dart';
import 'models/product_model.dart';

class ProductListPage extends StatefulWidget {
  const ProductListPage({super.key});

  @override
  State<ProductListPage> createState() => _ProductListPageState();
}

class _ProductListPageState extends State<ProductListPage> {
  // 模拟商品数据
  late List<ProductModel> _products;

  @override
  void initState() {
    super.initState();
    _products = [
      const ProductModel(
        id: '1',
        imageUrl: 'https://example.com/phone1.jpg',
        title: '新款智能手机 5G全网通 256G大内存 超长续航',
        price: 2999.99,
        sales: 1200,
        isFavorite: false,
      ),
      const ProductModel(
        id: '2',
        imageUrl: 'https://example.com/laptop1.jpg',
        title: '轻薄笔记本电脑 16英寸高清屏 16G+512G 办公游戏两用',
        price: 4999.99,
        sales: 850,
        isFavorite: true,
      ),
      const ProductModel(
        id: '3',
        imageUrl: 'https://example.com/tablet1.jpg',
        title: '平板电脑 10.9英寸 全面屏 网课学习 娱乐办公',
        price: 1899.99,
        sales: 2100,
        isFavorite: false,
      ),
    ];
  }

  // 切换收藏状态
  void _toggleFavorite(String productId) {
    setState(() {
      _products = _products.map((product) {
        if (product.id == productId) {
          return ProductModel(
            id: product.id,
            imageUrl: product.imageUrl,
            title: product.title,
            price: product.price,
            sales: product.sales,
            isFavorite: !product.isFavorite,
          );
        }
        return product;
      }).toList();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('商品列表')),
      body: Padding(
        padding: const EdgeInsets.all(10),
        child: GridView.count(
          crossAxisCount: 2, // 每行2个卡片
          crossAxisSpacing: 10, // 水平间距
          mainAxisSpacing: 10, // 垂直间距
          childAspectRatio: 0.8, // 宽高比,保证卡片比例统一
          children: _products.map((product) {
            return ProductCard(
              product: product,
              onFavoriteTap: () => _toggleFavorite(product.id),
              onTap: () {
                // 跳转到商品详情页
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('进入${product.title}详情页')),
                );
              },
              showSales: true,
            );
          }).toList(),
        ),
      ),
    );
  }
}

五、实战案例 3:高性能封装 —— 防抖输入框

5.1 需求分析

搜索框、表单输入框经常需要 “防抖” 处理(输入完成后延迟执行搜索 / 校验,避免频繁请求),若每个输入框都写防抖逻辑,代码冗余且易出错,适合封装为通用组件。

5.2 封装实现

创建widgets/debounce_text_field.dart

dart

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

/// 防抖输入框组件
/// 输入完成后延迟[debounceDelay]执行[onChanged]回调
class DebounceTextField extends StatefulWidget {
  // 防抖延迟时间(默认500ms)
  final Duration debounceDelay;
  // 输入框控制器(可选,外部可控制输入内容)
  final TextEditingController? controller;
  // 防抖后的输入回调
  final Function(String) onChanged;
  // 输入框提示文字
  final String hintText;
  // 输入框样式(可选)
  final InputDecoration? decoration;
  // 输入框焦点(可选)
  final FocusNode? focusNode;
  // 是否禁用
  final bool enabled;

  const DebounceTextField({
    super.key,
    this.debounceDelay = const Duration(milliseconds: 500),
    this.controller,
    required this.onChanged,
    this.hintText = '',
    this.decoration,
    this.focusNode,
    this.enabled = true,
  });

  @override
  State<DebounceTextField> createState() => _DebounceTextFieldState();
}

class _DebounceTextFieldState extends State<DebounceTextField> {
  // 防抖定时器
  Timer? _debounceTimer;
  // 内部控制器(若外部未传入)
  late TextEditingController _internalController;

  @override
  void initState() {
    super.initState();
    // 优先使用外部控制器,否则创建内部控制器
    _internalController = widget.controller ?? TextEditingController();
  }

  @override
  void dispose() {
    // 销毁时取消定时器,避免内存泄漏
    _debounceTimer?.cancel();
    // 仅销毁内部创建的控制器(外部控制器由外部管理)
    if (widget.controller == null) {
      _internalController.dispose();
    }
    super.dispose();
  }

  // 处理输入变化,实现防抖逻辑
  void _handleTextChanged(String value) {
    // 取消之前的定时器
    _debounceTimer?.cancel();
    // 新建定时器,延迟执行回调
    _debounceTimer = Timer(widget.debounceDelay, () {
      // 确保组件未销毁
      if (mounted) {
        widget.onChanged(value);
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final controller = widget.controller ?? _internalController;

    return TextField(
      controller: controller,
      focusNode: widget.focusNode,
      enabled: widget.enabled,
      decoration: widget.decoration ??
          InputDecoration(
            hintText: widget.hintText,
            border: const OutlineInputBorder(
              borderRadius: BorderRadius.all(Radius.circular(8)),
              borderSide: BorderSide(color: Colors.grey),
            ),
            enabledBorder: const OutlineInputBorder(
              borderRadius: BorderRadius.all(Radius.circular(8)),
              borderSide: BorderSide(color: Colors.grey),
            ),
            focusedBorder: const OutlineInputBorder(
              borderRadius: BorderRadius.all(Radius.circular(8)),
              borderSide: BorderSide(color: Colors.blue),
            ),
            disabledBorder: const OutlineInputBorder(
              borderRadius: BorderRadius.all(Radius.circular(8)),
              borderSide: BorderSide(color: Colors.grey.shade300),
            ),
            contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
          ),
      onChanged: _handleTextChanged,
    );
  }
}

5.3 关键技术点解析

  1. 防抖核心逻辑
    • 通过Timer实现延迟执行,每次输入时取消上一个定时器,重新计时;
    • mounted判断:避免组件销毁后执行回调导致异常。
  2. 控制器管理
    • 支持外部传入TextEditingController,满足 “外部控制输入内容” 的场景;
    • 内部创建的控制器在dispose时销毁,避免内存泄漏。
  3. 样式兼容
    • 提供默认样式,同时支持外部覆盖decoration,兼顾易用性和定制性。

5.4 使用示例

dart

import 'package:flutter/material.dart';
import 'widgets/debounce_text_field.dart';

class SearchPage extends StatefulWidget {
  const SearchPage({super.key});

  @override
  State<SearchPage> createState() => _SearchPageState();
}

class _SearchPageState extends State<SearchPage> {
  // 搜索结果
  List<String> _searchResults = [];
  // 加载状态
  bool _isLoading = false;

  // 模拟搜索接口
  Future<void> _search(String keyword) async {
    if (keyword.isEmpty) {
      setState(() {
        _searchResults = [];
        _isLoading = false;
      });
      return;
    }

    setState(() => _isLoading = true);
    // 模拟网络请求
    await Future.delayed(const Duration(seconds: 1));
    // 模拟搜索结果
    final results = [
      '$keyword - 结果1',
      '$keyword - 结果2',
      '$keyword - 结果3',
    ];

    if (mounted) {
      setState(() {
        _searchResults = results;
        _isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('防抖搜索示例')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            // 防抖输入框
            DebounceTextField(
              hintText: '请输入搜索关键词',
              debounceDelay: const Duration(milliseconds: 600),
              onChanged: _search,
            ),
            const SizedBox(height: 20),
            // 搜索结果展示
            if (_isLoading)
              const Center(child: CircularProgressIndicator())
            else if (_searchResults.isEmpty)
              const Center(child: Text('请输入关键词搜索'))
            else
              Expanded(
                child: ListView.builder(
                  itemCount: _searchResults.length,
                  itemBuilder: (context, index) {
                    return ListTile(
                      title: Text(_searchResults[index]),
                      onTap: () {
                        ScaffoldMessenger.of(context).showSnackBar(
                          SnackBar(content: Text('选择了${_searchResults[index]}')),
                        );
                      },
                    );
                  },
                ),
              ),
          ],
        ),
      ),
    );
  }
}

六、自定义组件封装的避坑指南

6.1 常见错误

  1. 过度封装:❌ 为简单的文本展示封装组件,参数比逻辑还多;✅ 只封装重复出现、有复杂样式 / 逻辑的部分。
  2. 强耦合:❌ 组件内部依赖全局状态、上下文,无法独立使用;✅ 通过参数传递依赖,组件自身无外部依赖。
  3. 内存泄漏:❌ 定时器、控制器未在dispose中销毁;✅ 组件销毁时清理所有资源(定时器、焦点、控制器等)。
  4. 参数冗余:❌ 暴露过多参数,增加使用成本;✅ 只暴露核心可定制参数,默认值覆盖 80% 场景。

6.2 性能优化技巧

  1. const 构造函数:纯展示组件使用const构造函数,避免重复构建;
  2. 缓存计算结果:复杂样式计算(如渐变、阴影)可缓存,避免每次 build 重新计算;
  3. 懒加载:列表项组件可结合ListView.builder实现懒加载,减少首屏渲染压力;
  4. 避免不必要的重建:使用ValueNotifierProvider等管理组件内部状态,避免整组件重建。

七、总结

Flutter 自定义组件封装的本质是 “抽象共性、暴露个性”—— 将重复的样式、逻辑抽象为组件内部实现,通过参数暴露可定制的部分。本文通过 “渐变按钮(基础样式)→ 商品卡片(业务组件)→ 防抖输入框(高性能逻辑)” 三个案例,覆盖了 80% 的日常封装场景。

核心收获:

  1. 封装前先明确组件的 “单一职责”,避免功能堆砌;
  2. 参数设计遵循 “最小可用 + 默认值” 原则,降低使用成本;
  3. 注重容错处理(如图片占位、空值判断),提升组件健壮性;
  4. 资源管理(定时器、控制器)是避免内存泄漏的关键;
  5. 高性能封装需关注 “避免不必要的重建” 和 “资源及时释放”。

建议你基于本文案例,尝试封装业务中重复的组件(如评论项、表单项、弹窗),逐步构建自己的组件库。一个优秀的组件库,能让你从 “重复写代码” 转向 “专注业务逻辑”,大幅提升开发效率。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐