Flutter 三方库 url_launcher + link_preview 的鸿蒙化适配与实战指南


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

Yo 大家好!,上海某高校计算机专业大一学生 🔗!今天来聊聊链接处理这个功能!

做聊天 App 不可避免会遇到链接:用户分享一个网址、商品链接、文章链接......这时候你的 App 需要能够识别链接、预览链接内容、甚至直接在 App 里打开!

今天手把手教你在 Flutter 鸿蒙 App 里实现完整的链接处理方案!

一、为什么需要链接处理?

聊天场景下,链接处理太重要了:

  • 识别:用户发了一条消息,里面包含链接,需要识别出来
  • 预览:显示链接的标题、描述、图片,让用户知道链接内容
  • 打开:点击链接,在浏览器或 App 内打开

没有链接处理,用户看到的就是一串网址,体验很差!

二、依赖配置

yaml 复制代码
dependencies:
  url_launcher: ^6.3.1
  dio: ^5.4.3+1  # 用于获取链接预览信息

AtomGit 适配说明:url_launcher 在鸿蒙上有专门实现,链接识别和打开功能正常!dio 是纯 Dart 库,零适配成本!

三、封装链接处理服务

dart 复制代码
import 'package:flutter/foundation.dart';
import 'package:dio/dio.dart';
import 'package:url_launcher/url_launcher.dart';

/// 链接预览服务
/// 自动解析URL生成预览信息
class LinkPreviewService {
  static LinkPreviewService? _instance;
  static LinkPreviewService get instance => _instance ??= LinkPreviewService._();

  final Dio _dio;

  LinkPreviewService._() : _dio = Dio() {
    // 配置 Dio
    _dio.options.connectTimeout = const Duration(seconds: 10);
    _dio.options.receiveTimeout = const Duration(seconds: 10);
    _dio.options.headers = {
      'User-Agent': 'Mozilla/5.0 (compatible; Flutter/1.0)',
    };
  }

  // 缓存预览数据
  LinkPreviewData? _cachedData;

1. 获取链接预览

dart 复制代码
  /// 获取链接预览【核心方法】
  Future<LinkPreviewData?> getPreview(String url) async {
    // 先检查缓存
    if (_cachedData != null && _cachedData!.url == url) {
      return _cachedData;
    }

    try {
      // 1. 发送 HEAD 请求获取基本信息
      final response = await _dio.head(url);
      final contentType = response.headers.value('content-type') ?? '';
      
      // 2. 解析 URL 获取域名
      final uri = Uri.parse(url);
      final domain = uri.host;
      
      // 3. 初始化默认值
      String title = domain;
      String? description;
      String? imageUrl;

      // 4. 如果是 HTML 页面,尝试获取更多信息
      if (contentType.contains('text/html')) {
        try {
          final htmlResponse = await _dio.get(url);
          final html = htmlResponse.data.toString();
          
          // 提取标题
          title = _extractTitle(html) ?? title;
          
          // 提取描述
          description = _extractDescription(html);
          
          // 提取图片
          imageUrl = _extractImage(html, url);
        } catch (e) {
          debugPrint('获取HTML内容失败: $e');
        }
      }

      _cachedData = LinkPreviewData(
        url: url,
        title: title,
        description: description,
        imageUrl: imageUrl,
        domain: domain,
        contentType: contentType,
      );

      debugPrint('获取预览成功: $title');
      return _cachedData;
    } catch (e) {
      debugPrint('获取预览失败: $e');
      return null;
    }
  }

2. 提取页面信息

dart 复制代码
  /// 提取页面标题【重点方法】
  String? _extractTitle(String html) {
    // 匹配 <title>xxx</title>
    final titleRegex = RegExp(
      r'<title[^>]*>([^<]+)<\/title>',
      caseSensitive: false,
      multiLine: true,
    );
    final match = titleRegex.firstMatch(html);
    return match?.group(1)?.trim();
  }

  /// 提取页面描述【重点方法】
  String? _extractDescription(String html) {
    // 1. 尝试 meta description
    final descRegex = RegExp(
      r'<meta[^>]+name=["\x27]description["\x27][^>]+content=["\x27]([^"\x27]+)["\x27]',
      caseSensitive: false,
      multiLine: true,
    );
    var match = descRegex.firstMatch(html);
    if (match != null) {
      return match.group(1)?.trim();
    }

    // 2. 尝试 og:description
    final ogDescRegex = RegExp(
      r'<meta[^>]+property=["\x27]og:description["\x27][^>]+content=["\x27]([^"\x27]+)["\x27]',
      caseSensitive: false,
      multiLine: true,
    );
    match = ogDescRegex.firstMatch(html);
    return match?.group(1)?.trim();
  }

  /// 提取预览图片【重点方法】
  String? _extractImage(String html, String baseUrl) {
    // 尝试 og:image
    final imageRegex = RegExp(
      r'<meta[^>]+property=["\x27]og:image["\x27][^>]+content=["\x27]([^"\x27]+)["\x27]',
      caseSensitive: false,
      multiLine: true,
    );
    var match = imageRegex.firstMatch(html);
    if (match != null) {
      var imageUrl = match.group(1);
      // 处理相对路径
      if (imageUrl != null && !imageUrl.startsWith('http')) {
        final uri = Uri.parse(baseUrl);
        if (imageUrl.startsWith('//')) {
          imageUrl = '${uri.scheme}:$imageUrl';
        } else if (imageUrl.startsWith('/')) {
          imageUrl = '${uri.scheme}://${uri.host}$imageUrl';
        }
      }
      return imageUrl;
    }
    return null;
  }

3. 链接识别

dart 复制代码
  /// 判断文本是否为 URL
  bool isUrl(String text) {
    final urlRegex = RegExp(
      r'^https?:\/\/([\w\-]+\.)+[\w\-]+(\/[\w\-\.~:/\?#\[\]@!$&()*+,;=%]*)?$',
      caseSensitive: false,
    );
    return urlRegex.hasMatch(text);
  }

  /// 从文本中提取所有 URL【实用方法】
  List<String> extractUrls(String text) {
    final urlRegex = RegExp(
      r'https?:\/\/([\w\-]+\.)+[\w\-]+(\/[\w\-\.~:/\?#\[\]@!$&()*+,;=%]*)?',
      caseSensitive: false,
    );
    return urlRegex.allMatches(text).map((m) => m.group(0)!).toList();
  }

4. 打开链接

dart 复制代码
  /// 在浏览器中打开链接【核心方法】
  Future<bool> openUrl(String url) async {
    try {
      final uri = Uri.parse(url);
      if (await canLaunchUrl(uri)) {
        // LaunchMode.externalApplication 在外部浏览器打开
        await launchUrl(uri, mode: LaunchMode.externalApplication);
        debugPrint('打开链接成功: $url');
        return true;
      }
      debugPrint('无法打开链接: $url');
      return false;
    } catch (e) {
      debugPrint('打开链接失败: $e');
      return false;
    }
  }

  /// 在 App 内打开链接(WebView)
  Future<bool> openUrlInApp(String url) async {
    try {
      final uri = Uri.parse(url);
      if (await canLaunchUrl(uri)) {
        // LaunchMode.inAppWebView 在 App 内打开(需要 webview_flutter)
        await launchUrl(uri, mode: LaunchMode.inAppWebView);
        return true;
      }
      return false;
    } catch (e) {
      debugPrint('App内打开链接失败: $e');
      return false;
    }
  }

5. 测试方法

dart 复制代码
  /// 测试链接预览功能
  Future<Map<String, dynamic>> testLinkPreview() async {
    try {
      final testUrls = [
        'https://github.com',
        'https://flutter.dev',
        'https://dart.dev',
      ];

      final results = <Map<String, dynamic>>[];
      
      for (final url in testUrls) {
        final preview = await getPreview(url);
        results.add({
          'url': url,
          'title': preview?.title ?? '获取失败',
          'success': preview != null,
        });
      }

      return {
        'success': true,
        'message': '链接预览测试完成',
        'results': results,
      };
    } catch (e) {
      return {'success': false, 'message': '链接预览测试失败: $e'};
    }
  }
}

/// 链接预览数据模型
class LinkPreviewData {
  final String url;
  final String title;
  final String? description;
  final String? imageUrl;
  final String domain;
  final String? contentType;

  LinkPreviewData({
    required this.url,
    required this.title,
    this.description,
    this.imageUrl,
    required this.domain,
    this.contentType,
  });
}

四、在聊天消息中识别链接

dart 复制代码
class ChatMessageWidget extends StatelessWidget {
  final ChatMessage message;

  const ChatMessageWidget({super.key, required this.message});

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(12),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // 消息内容
          _buildContent(context),
          // 链接预览卡片
          if (_hasUrl(message.content)) _buildLinkPreview(context),
        ],
      ),
    );
  }

  /// 判断消息是否包含链接
  bool _hasUrl(String content) {
    final urlRegex = RegExp(
      r'https?:\/\/([\w\-]+\.)+[\w\-]+',
      caseSensitive: false,
    );
    return urlRegex.hasMatch(content);
  }

  /// 提取第一个链接
  String? _extractFirstUrl(String content) {
    final urlRegex = RegExp(
      r'https?:\/\/([\w\-]+\.)+[\w\-]+(\/[\w\-\.~:/\?#\[\]@!$&()*+,;=%]*)?',
      caseSensitive: false,
    );
    final match = urlRegex.firstMatch(content);
    return match?.group(0);
  }

  /// 构建消息内容(可点击的链接)
  Widget _buildContent(BuildContext context) {
    final content = message.content;
    final urls = _extractAllUrls(content);
    
    if (urls.isEmpty) {
      return Text(content);
    }

    // 将文本分割成普通文本和链接
    final spans = <InlineSpan>[];
    int lastEnd = 0;

    for (final url in urls) {
      final start = content.indexOf(url, lastEnd);
      if (start > lastEnd) {
        spans.add(TextSpan(text: content.substring(lastEnd, start)));
      }
      spans.add(
        TextSpan(
          text: url,
          style: const TextStyle(
            color: Color(0xFF6366F1),
            decoration: TextDecoration.underline,
          ),
          recognizer: TapGestureRecognizer()
            ..onTap = () => _openUrl(context, url),
        ),
      );
      lastEnd = start + url.length;
    }

    if (lastEnd < content.length) {
      spans.add(TextSpan(text: content.substring(lastEnd)));
    }

    return Text.rich(TextSpan(children: spans));
  }

  /// 构建链接预览卡片
  Widget _buildLinkPreview(BuildContext context) {
    final url = _extractFirstUrl(message.content);
    if (url == null) return const SizedBox.shrink();

    return FutureBuilder<LinkPreviewData?>(
      future: LinkPreviewService.instance.getPreview(url),
      builder: (context, snapshot) {
        if (!snapshot.hasData) {
          return const SizedBox.shrink();
        }

        final preview = snapshot.data!;
        return GestureDetector(
          onTap: () => _openUrl(context, url),
          child: Container(
            margin: const EdgeInsets.only(top: 8),
            padding: const EdgeInsets.all(12),
            decoration: BoxDecoration(
              color: Colors.grey[100],
              borderRadius: BorderRadius.circular(12),
              border: Border.all(color: Colors.grey[300]!),
            ),
            child: Row(
              children: [
                // 预览图
                if (preview.imageUrl != null)
                  ClipRRect(
                    borderRadius: BorderRadius.circular(8),
                    child: CachedNetworkImage(
                      imageUrl: preview.imageUrl!,
                      width: 60,
                      height: 60,
                      fit: BoxFit.cover,
                      errorWidget: (_, __, ___) => Container(
                        width: 60,
                        height: 60,
                        color: Colors.grey[300],
                        child: const Icon(Icons.link),
                      ),
                    ),
                  ),
                const SizedBox(width: 12),
                // 预览信息
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        preview.title,
                        style: const TextStyle(
                          fontWeight: FontWeight.w600,
                          fontSize: 14,
                        ),
                        maxLines: 2,
                        overflow: TextOverflow.ellipsis,
                      ),
                      const SizedBox(height: 4),
                      Text(
                        preview.domain,
                        style: TextStyle(
                          color: Colors.grey[600],
                          fontSize: 12,
                        ),
                      ),
                    ],
                  ),
                ),
                // 打开图标
                const Icon(Icons.open_in_new, size: 20, color: Colors.grey),
              ],
            ),
          ),
        );
      },
    );
  }

  List<String> _extractAllUrls(String content) {
    final urlRegex = RegExp(
      r'https?:\/\/([\w\-]+\.)+[\w\-]+(\/[\w\-\.~:/\?#\[\]@!$&()*+,;=%]*)?',
      caseSensitive: false,
    );
    return urlRegex.allMatches(content).map((m) => m.group(0)!).toList();
  }

  void _openUrl(BuildContext context, String url) async {
    final success = await LinkPreviewService.instance.openUrl(url);
    if (!success && context.mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('无法打开链接')),
      );
    }
  }
}

五、踩坑纪实

踩坑1:链接识别不准确 🔍

一开始用简单的正则匹配链接,结果把带空格的 URL 也识别进去了,或者把某些特殊字符漏掉了。后来不断完善正则:

dart 复制代码
// 改进后的正则
final urlRegex = RegExp(
  r'https?:\/\/([\w\-]+\.)+[\w\-]+(\/[\w\-\.~:/\?#\[\]@!$&()*+,;=%]*)?',
  caseSensitive: false,
);

踩坑2:相对路径图片无法显示 🖼️

有些网站用相对路径存储图片,直接拿过来用会显示不出来。需要转换成绝对路径:

dart 复制代码
// 处理相对路径
if (imageUrl != null && !imageUrl.startsWith('http')) {
  final uri = Uri.parse(baseUrl);
  if (imageUrl.startsWith('//')) {
    imageUrl = '${uri.scheme}:$imageUrl';
  } else if (imageUrl.startsWith('/')) {
    imageUrl = '${uri.scheme}://${uri.host}$imageUrl';
  }
}

踩坑3:网页请求超时 ⏱️

有些网站响应很慢,导致预览加载很久。后来加了超时限制和缓存:

dart 复制代码
_dio.options.connectTimeout = const Duration(seconds: 10);
_dio.options.receiveTimeout = const Duration(seconds: 10);

// 缓存已加载的预览
if (_cachedData != null && _cachedData!.url == url) {
  return _cachedData;
}

踩坑4:部分网站禁止爬取 🚫

有些网站会检查 User-Agent 或者直接禁止爬取。解决方案是设置合理的请求头:

dart 复制代码
_dio.options.headers = {
  'User-Agent': 'Mozilla/5.0 (compatible; Flutter/1.0)',
};

六、效果展示

七、总结心得

链接处理虽然看起来简单,但要做好还是要花不少心思的。正则匹配、网页解析、路径处理、异常处理......每个环节都有坑!

核心要点:

  1. 正则表达式要完善,考虑各种 URL 格式
  2. 相对路径图片要转换成绝对路径
  3. 加请求超时,避免长时间等待
  4. 做好缓存,避免重复请求

学习心得:

通过这个功能,我学会了网页解析的基本方法。虽然是简单的正则匹配,但让我理解了网页抓取的原理!

后续计划:

  • 研究 Open Graph 协议的更多字段
  • 实现更强大的链接预览卡片
  • 添加分享链接功能

今天的分享就到这里!有问题评论区见!

相关推荐
拉拉尼亚2 小时前
flutter轻量级本地存储shared_preferences 教程
flutter·安卓
心走2 小时前
记录鸿蒙相机输出预览流报错问题(CAMERA_SERVICE_FATAL_ERROR)
harmonyos
jiejiejiejie_3 小时前
自定义导航栏组件
flutter·华为·harmonyos
云_杰3 小时前
拒绝社死!旁边有人偷瞄?教你给App加上鸿蒙系统级“防窥”黑科技!
安全·harmonyos
想你依然心痛3 小时前
HarmonyOS 6(API 23)实战:基于 Face AR 疼痛评估与 Body AR 姿态追踪的“智能康复训练助手“
华为·ar·harmonyos·悬浮导航·沉浸光感
IntMainJhy4 小时前
Flutter 三方库 audioplayers 的鸿蒙化适配与实战指南
flutter·华为·harmonyos
liulian09164 小时前
Flutter for OpenHarmony 渐变色UI设计实战:LinearGradient与RadialGradient深度应用
flutter·华为·harmonyos
爱艺江河4 小时前
HarmonyOS智慧风控:基于分布式架构的安全与创新实践
分布式·架构·harmonyos