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)',
};
六、效果展示

七、总结心得
链接处理虽然看起来简单,但要做好还是要花不少心思的。正则匹配、网页解析、路径处理、异常处理......每个环节都有坑!
核心要点:
- 正则表达式要完善,考虑各种 URL 格式
- 相对路径图片要转换成绝对路径
- 加请求超时,避免长时间等待
- 做好缓存,避免重复请求
学习心得:
通过这个功能,我学会了网页解析的基本方法。虽然是简单的正则匹配,但让我理解了网页抓取的原理!
后续计划:
- 研究 Open Graph 协议的更多字段
- 实现更强大的链接预览卡片
- 添加分享链接功能
今天的分享就到这里!有问题评论区见!