先看效果

请添加图片描述

在鸿蒙真机 上模拟器上成功运行后的效果
在这里插入图片描述

前言

作为一名 Flutter 开发者,我在实际项目中经常遇到这样的场景:用户需要从几百甚至上千个选项中选择一个,而原生的 DropdownButton 在这种场景下就显得力不从心了。它不支持搜索,列表过长时滚动体验也不够流畅。

于是,我决定自己动手,打造一个既美观又高性能的可搜索下拉选择框组件。这个组件不仅要支持搜索功能,还要在 HarmonyOS 设备上保持丝滑的交互体验。

Flutter + HarmonyOS 混合开发

Flutter 负责 UI 渲染和业务逻辑,HarmonyOS 提供应用容器和平台能力。这种架构让我们既能享受 Flutter 的跨平台优势,又能充分利用 HarmonyOS 的原生特性。

项目核心功能包括:

  • 可搜索的下拉选择框组件
  • 玻璃态卡片效果
  • 原生下拉框对比演示
  • 性能优化实践

技术栈与开发环境

在开始之前,我们需要准备以下环境:

Flutter SDK: 3.6.2 或更高版本
Dart SDK: 3.6.2 或更高版本
DevEco Studio: HarmonyOS 开发工具
开发语言: Dart(Flutter 端)+ ArkTS(HarmonyOS 端)

项目使用 Material 3 设计规范,采用深色主题,整体风格现代且优雅。

项目结构解析

让我先带你看看项目的整体结构,这样你就能快速定位到需要的代码:

lib/
├── main.dart                 # 应用入口
├── app/
│   └── app.dart             # 应用配置(主题、路由)
├── models/
│   └── demo_option.dart     # 数据模型
├── pages/
│   └── dropdown_demo_page.dart  # 演示页面
└── widgets/
    ├── glass_card.dart      # 玻璃态卡片
    └── searchable_dropdown.dart  # 可搜索下拉框

这种结构清晰明了,每个文件职责单一,便于维护和扩展。

数据模型设计:DemoOption 类的精妙之处

数据模型是组件的基础,一个好的数据模型能让组件更加灵活。让我们看看 DemoOption 是如何设计的:


class DemoOption {
  const DemoOption({
    required this.id,
    required this.title,
    this.subtitle,
    this.icon,
    this.searchText,
  });

  final String id;
  final String title;
  final String? subtitle;
  final IconData? icon;
  final String? searchText;  // 可选的搜索文本(拼音/别名等)
}

设计亮点

  1. 不可变设计:使用 @immutableconst 构造函数,确保数据不会被意外修改
  2. 灵活的搜索支持searchText 字段允许我们为每个选项添加额外的搜索关键词,比如拼音、别名等
  3. 可选字段subtitleiconsearchText 都是可选的,让组件适应不同的使用场景

使用示例

const DemoOption(
  id: 'beijing',
  title: '北京',
  subtitle: 'Beijing',
  icon: Icons.location_city,
  searchText: '京 bj beijing',  // 支持拼音和英文搜索
)

这种设计让搜索功能更加强大,用户输入"bj"、"beijing"或"京"都能找到"北京"这个选项。

玻璃态卡片组件:GlassCard 实现原理

玻璃态(Glassmorphism)是近年来非常流行的设计风格,它通过毛玻璃效果营造出层次感和现代感。我们的 GlassCard 组件就是基于这个理念设计的。

class GlassCard extends StatelessWidget {
  const GlassCard({
    super.key,
    required this.child,
    this.padding = const EdgeInsets.all(16),
    this.borderRadius = const BorderRadius.all(Radius.circular(20)),
    this.blurSigma = 18,  // 模糊度,默认18
  });

  final Widget child;
  final EdgeInsetsGeometry padding;
  final BorderRadius borderRadius;
  final double blurSigma;

核心实现


Widget build(BuildContext context) {
  final scheme = Theme.of(context).colorScheme;

  return ClipRRect(
    borderRadius: borderRadius,
    child: BackdropFilter(  // 关键:毛玻璃效果
      filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
      child: DecoratedBox(
        decoration: BoxDecoration(
          borderRadius: borderRadius,
          gradient: LinearGradient(
            begin: Alignment.topLeft,
            end: Alignment.bottomRight,
            colors: [
              scheme.surface.withValues(alpha: 0.55),  // 半透明渐变
              scheme.surface.withValues(alpha: 0.22),
            ],
          ),
          border: Border.all(
            color: scheme.outline.withValues(alpha: 0.22),
            width: 1,
          ),
        ),
        child: Padding(
          padding: padding,
          child: child,
        ),
      ),
    ),
  );
}

技术要点

  1. BackdropFilter:这是实现毛玻璃效果的关键,它会对背景进行模糊处理
  2. 半透明渐变:使用 withValues(alpha: 0.55) 创建半透明效果,让背景内容若隐若现
  3. 边框装饰:添加半透明边框,增强层次感

使用技巧

  • blurSigma 值越大,模糊效果越明显,但性能开销也越大,建议在 15-25 之间
  • 在半透明背景上使用效果最佳
  • 可以配合渐变背景使用,营造更丰富的视觉效果

可搜索下拉框核心组件:SearchableDropdown 深度解析

这是整个项目的核心组件,它实现了可搜索、高性能的下拉选择功能。让我带你深入理解它的实现。

组件定义与类型系统

class SearchableDropdown<T> extends StatefulWidget {
  const SearchableDropdown({
    super.key,
    required this.title,
    required this.items,
    required this.labelOf,
    required this.onChanged,
    this.value,
    this.hintText = '请选择',
    this.subtitleOf,
    this.leadingOf,
    this.searchTextOf,
    this.isSame,
    this.enabled = true,
  });

泛型设计:使用 <T> 泛型让组件可以处理任何类型的数据,不仅限于 DemoOption,提高了组件的复用性。

回调函数类型定义

typedef SearchTextBuilder<T> = String Function(T item);
typedef ItemLabelBuilder<T> = String Function(T item);
typedef ItemSubtitleBuilder<T> = String? Function(T item);
typedef ItemLeadingBuilder<T> = Widget? Function(T item);
typedef ItemEquality<T> = bool Function(T a, T b);

使用 typedef 定义回调函数类型,让代码更清晰,也方便 IDE 提供更好的代码提示。

状态管理:ValueNotifier 的巧妙运用

class _SearchableDropdownState<T> extends State<SearchableDropdown<T>> {
  late List<String> _searchKeys;  // 预处理的搜索关键词列表
  Timer? _debounce;  // 防抖定时器
  final ValueNotifier<String> _query = ValueNotifier<String>('');  // 搜索关键词
  final ValueNotifier<List<int>> _filtered = ValueNotifier<List<int>>(<int>[]);  // 过滤后的索引列表

为什么使用 ValueNotifier

传统的 setState() 会重建整个组件树,而 ValueNotifier 配合 ValueListenableBuilder 可以实现局部重建,只更新需要变化的部分。这在搜索场景下性能提升非常明显。

搜索关键词预处理

void _rebuildSearchKeys() {
  final searchTextOf = widget.searchTextOf;
  final subtitleOf = widget.subtitleOf;
  _searchKeys = List<String>.generate(widget.items.length, (i) {
    final item = widget.items[i];
    final base = <String>[
      widget.labelOf(item),
      if (subtitleOf != null) (subtitleOf(item) ?? ''),
      if (searchTextOf != null) searchTextOf(item),
    ].join(' ');
    return _normalize(base);  // 标准化:转小写、去空格
  }, growable: false);
}

优化策略

  1. 预处理:在初始化时就把所有选项的搜索文本处理好,避免每次搜索都重新计算
  2. 索引存储:使用索引列表而不是对象列表,减少内存占用
  3. 标准化处理:统一转小写、去空格,提高搜索准确性

防抖搜索机制:性能优化的关键

防抖(Debounce)是前端开发中常用的性能优化技巧。它的核心思想是:在用户停止输入一段时间后再执行搜索,避免频繁触发。

void _onQueryChanged() {
  _debounce?.cancel();  // 取消之前的定时器
  _debounce = Timer(const Duration(milliseconds: 80), () {
    if (!mounted) return;  // 安全检查
    _applyFilter(_query.value);
  });
}

工作原理

  1. 用户每输入一个字符,都会触发 _onQueryChanged
  2. 每次触发时,先取消之前的定时器
  3. 创建新的定时器,80ms 后执行搜索
  4. 如果用户在 80ms 内继续输入,定时器会被取消并重新创建

为什么是 80ms

经过测试,80ms 是一个平衡点:既能快速响应用户输入,又不会因为过于频繁的搜索导致卡顿。在 HarmonyOS 设备上,这个值能保证流畅的交互体验。

注意事项

  • 使用 mounted 检查确保组件还在树中,避免内存泄漏
  • dispose() 中记得取消定时器

虚拟列表渲染:处理大量数据的秘诀

当选项数量达到几百甚至上千时,如果一次性渲染所有项,会导致严重的性能问题。虚拟列表(Virtual List)是解决这个问题的关键。

Expanded(
  child: ValueListenableBuilder<List<int>>(
    valueListenable: _filtered,
    builder: (context, indices, _) {
      if (indices.isEmpty) {
        return Center(
          child: Text('没有匹配结果'),
        );
      }

      return Scrollbar(
        child: ListView.builder(  // 关键:使用 builder 实现虚拟列表
          physics: const BouncingScrollPhysics(),
          itemCount: indices.length,
          itemBuilder: (context, pos) {
            final idx = indices[pos];  // 获取实际索引
            final item = widget.items[idx];
            final selected = _isSelected(item);

            return _OptionTile(
              title: widget.labelOf(item),
              subtitle: subtitleOf != null ? subtitleOf(item) : null,
              leading: leadingOf != null ? leadingOf(item) : null,
              selected: selected,
              onTap: () => Navigator.of(context).pop<T>(item),
            );
          },
        ),
      );
    },
  ),
)

ListView.builder 的优势

  1. 按需渲染:只渲染可见区域的项,滚动时才创建新的项
  2. 自动回收:离开可见区域的项会被回收,内存占用稳定
  3. 性能稳定:即使有 1000 个选项,性能依然流畅

索引映射技巧

使用 indices[pos] 而不是直接使用 pos,这样我们只需要存储过滤后的索引列表,而不是复制整个对象列表,大大减少了内存占用。

底部弹层交互:showModalBottomSheet 的巧妙运用

底部弹层是移动端常见的交互方式,它比传统的下拉菜单更适合移动设备。Flutter 的 showModalBottomSheet 提供了很好的支持。

Future<void> _open() async {
  if (!widget.enabled) return;

  final result = await showModalBottomSheet<T>(
    context: context,
    isScrollControlled: true,  // 允许自定义高度
    useSafeArea: true,  // 适配安全区域
    backgroundColor: Colors.transparent,  // 透明背景,使用自定义样式
    barrierColor: Colors.black.withValues(alpha: 0.55),  // 半透明遮罩
    builder: (context) {
      // 返回自定义的弹层内容
    },
  );

  widget.onChanged(result);  // 处理选择结果
}

关键参数说明

  • isScrollControlled: true:允许我们通过 ConstrainedBox 控制弹层高度
  • useSafeArea: true:自动适配刘海屏等安全区域
  • backgroundColor: Colors.transparent:配合 GlassCard 实现玻璃态效果

高度控制

ConstrainedBox(
  constraints: BoxConstraints(
    maxHeight: MediaQuery.sizeOf(context).height * 0.78,  // 最大高度为屏幕的 78%
  ),
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      // 弹层内容
    ],
  ),
)

这样设计既保证了内容可见,又不会占满整个屏幕,用户体验更好。

演示页面构建:DropdownDemoPage 完整实现

演示页面展示了组件的实际使用,同时也包含了性能优化的最佳实践。

页面结构

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

  
  State<DropdownDemoPage> createState() => _DropdownDemoPageState();
}

class _DropdownDemoPageState extends State<DropdownDemoPage> {
  late final List<DemoOption> _cities;  // 城市列表(160个选项)
  late final List<DemoOption> _tech;   // 技术栈列表(4个选项)

  DemoOption? _dropdownValue;      // 原生下拉框的值
  DemoOption? _searchableValue;    // 可搜索下拉框的值

  
  void initState() {
    super.initState();
    _cities = _buildCityOptions();  // 生成160个城市选项
    _tech = _buildTechOptions();    // 生成技术栈选项
    _dropdownValue = _tech.first;   // 默认选中第一个
  }

使用 late final 的优势

  • late:允许在声明时不初始化,在 initState 中初始化
  • final:初始化后不可修改,保证数据安全
  • 这种组合既灵活又安全

动态背景实现

class _AuroraBackground extends StatelessWidget {
  const _AuroraBackground();

  
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    final size = MediaQuery.sizeOf(context);

    return RepaintBoundary(  // 性能优化:隔离重绘区域
      child: Stack(
        children: [
          // 渐变背景
          DecoratedBox(
            decoration: BoxDecoration(
              gradient: LinearGradient(
                colors: [
                  const Color(0xFF070A12),
                  scheme.primary.withValues(alpha: 0.12),
                  scheme.tertiary.withValues(alpha: 0.10),
                  const Color(0xFF070A12),
                ],
              ),
            ),
          ),
          // 多个光晕效果
          _GlowBlob(...),
          _GlowBlob(...),
          _GlowBlob(...),
        ],
      ),
    );
  }
}

RepaintBoundary 的作用

背景是静态的,不需要频繁重绘。使用 RepaintBoundary 可以将背景隔离,避免因为其他组件的重绘导致背景也被重绘,提升性能。

使用示例

SearchableDropdown<DemoOption>(
  title: '选择城市(支持搜索)',
  items: _cities,  // 160个选项
  value: _searchableValue,
  hintText: '点我打开搜索选择',
  labelOf: (o) => o.title,  // 如何显示标题
  subtitleOf: (o) => o.subtitle,  // 如何显示副标题
  searchTextOf: (o) => '${o.title} ${o.subtitle ?? ''} ${o.searchText ?? ''}',  // 搜索文本
  leadingOf: (o) => o.icon == null
      ? null
      : _LeadingBadge(icon: o.icon!),  // 前置图标
  isSame: (a, b) => a.id == b.id,  // 如何判断两个选项相等
  onChanged: (v) => setState(() => _searchableValue = v),
)

回调函数的灵活性

通过回调函数,我们可以自定义每个选项的显示方式,这让组件非常灵活。比如:

  • labelOf:可以显示选项的任何属性
  • searchTextOf:可以组合多个字段作为搜索文本
  • isSame:可以自定义相等性判断逻辑

主题配置与样式定制

应用使用 Material 3 设计规范,深色主题,整体风格现代优雅。

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

  
  Widget build(BuildContext context) {
    const seed = Color(0xFF7C4DFF);  // 主题种子颜色
    final scheme = ColorScheme.fromSeed(
      seedColor: seed,
      brightness: Brightness.dark,  // 深色模式
    );

    return MaterialApp(
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: scheme,
        scaffoldBackgroundColor: const Color(0xFF070A12),  // 深色背景
        // ... 其他主题配置
      ),
    );
  }
}

ColorScheme.fromSeed 的优势

只需要提供一个种子颜色,Material 3 会自动生成一套协调的颜色方案,包括 primary、secondary、tertiary 等,非常方便。

HarmonyOS 集成要点

EntryAbility 配置

import { FlutterAbility, FlutterEngine } from '@ohos/flutter_ohos';
import { GeneratedPluginRegistrant } from '../plugins/GeneratedPluginRegistrant';

export default class EntryAbility extends FlutterAbility {
  configureFlutterEngine(flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine);
    GeneratedPluginRegistrant.registerWith(flutterEngine);  // 注册插件
  }
}

Index 页面嵌入

import { FlutterPage } from '@ohos/flutter_ohos';

@Entry(storage)
@Component
struct Index {
  @LocalStorageLink('viewId') viewId: string = "";

  build() {
    Column() {
      FlutterPage({ viewId: this.viewId })  // 嵌入 Flutter UI
    }
  }
}

注意事项

  • 确保 GeneratedPluginRegistrant 正确注册所有插件
  • 使用 LocalStorage 管理视图 ID,支持多实例
  • 处理返回键事件,确保 Flutter 端能正确响应

性能优化技巧总结

经过实际测试,这个组件在 HarmonyOS 设备上能保持 60fps 的流畅体验,即使处理 160 个选项也毫无压力。关键优化点包括:

  1. 防抖搜索:80ms 防抖,减少不必要的计算
  2. 索引列表:使用索引而不是对象,减少内存占用
  3. 虚拟列表:ListView.builder 按需渲染
  4. 局部重建:ValueNotifier + ValueListenableBuilder
  5. 预处理:搜索关键词提前处理,避免重复计算
  6. RepaintBoundary:隔离静态背景,避免不必要的重绘

常见问题与解决方案

问题1:搜索不准确

原因:搜索文本没有包含足够的关键词

解决:在 searchTextOf 回调中组合多个字段,包括标题、副标题、拼音等

searchTextOf: (o) => '${o.title} ${o.subtitle ?? ''} ${o.searchText ?? ''}'

问题2:性能问题

原因:选项数量过多,没有使用虚拟列表

解决:确保使用 ListView.builder,不要使用 ListViewColumn

问题3:弹层高度不合适

原因:没有设置 isScrollControlledConstrainedBox

解决

showModalBottomSheet(
  isScrollControlled: true,  // 必须设置
  builder: (context) => ConstrainedBox(
    constraints: BoxConstraints(
      maxHeight: MediaQuery.sizeOf(context).height * 0.78,
    ),
    // ...
  ),
)

最佳实践建议

  1. 数据预处理:如果选项数据是动态的,在数据变化时调用 _rebuildSearchKeys() 重新预处理
  2. 搜索文本设计:为每个选项设计丰富的搜索文本,包括拼音、别名、英文等
  3. 性能监控:在开发时使用 Flutter DevTools 监控性能,确保达到 60fps
  4. 用户体验:提供清晰的视觉反馈,比如选中状态、加载状态等
  5. 错误处理:处理边界情况,比如空列表、无搜索结果等

总结

关键收获:

  • 理解了防抖机制在搜索场景下的重要性
  • 掌握了虚拟列表的实现原理和优化技巧
  • 学会了使用 ValueNotifier 实现局部重建
  • 体验了玻璃态设计的实现方式

未来可以继续优化的方向:

  • 支持多选功能
  • 添加分组显示
  • 支持自定义选项样式
  • 添加动画效果增强交互体验

欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐