Flutter_OpenHarmony_三方库_url_launcher链接跳转适配详解

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


一、url_launcher 库简介

url_launcher 是 Flutter 官方维护的 URL 启动器插件,用于在应用中打开各种 URL。无论是打开网页、拨打电话、发送邮件、还是启动其他应用,url_launcher 都提供了统一的 API 接口。

📋 url_launcher 核心特点

特点 说明
网页打开 支持在外部浏览器或内置 WebView 中打开网页
电话拨打 支持调用系统拨号功能
邮件发送 支持打开系统邮件客户端
短信发送 支持打开系统短信应用
地图导航 支持打开地图应用进行导航
应用跳转 支持打开其他已安装应用
跨平台兼容 支持 Android、iOS、Web、Windows、Linux、macOS、OpenHarmony

平台功能支持对比

功能 Android iOS Web OpenHarmony
打开网页(外部) ✔️ ✔️ ✔️ ✔️
打开网页(内置 WebView) ✔️ ✔️ ✔️
拨打电话 ✔️ ✔️ ✔️
发送邮件 ✔️ ✔️ ✔️
发送短信 ✔️ ✔️ ✔️
打开地图 ✔️ ✔️ ✔️
检查 URL 是否可用 ✔️ ✔️ ✔️
关闭内置 WebView ✔️ ✔️ ✔️

使用场景

  • 用户协议/隐私政策跳转
  • 客服联系方式(电话/邮件)
  • 社交媒体分享
  • 应用内打开外部链接
  • 地图导航集成
  • 应用商店跳转

二、OpenHarmony 适配版本

2.1 环境说明

组件 版本
Flutter 3.27.5
HarmonyOS 6.0
url_launcher 6.3.1 (OpenHarmony 适配版本)

2.2 引入方式

pubspec.yaml 文件中添加以下依赖配置:

yaml 复制代码
dependencies:
  flutter:
    sdk: flutter

  # url_launcher OpenHarmony 适配版本
  url_launcher:
    git:
      url: https://atomgit.com/openharmony-tpc/flutter_packages
      path: packages/url_launcher/url_launcher
      ref: br_url_launcher_v6.3.1_ohos

2.3 获取依赖

配置完成后,在项目根目录执行:

bash 复制代码
flutter pub get

2.4 权限配置

url_launcher 使用系统原生的 URL 处理机制,一般不需要额外权限。但如果需要访问网络资源,建议配置网络权限:

打开 ohos/entry/src/main/module.json5,在 requestPermissions 中添加:

json 复制代码
"requestPermissions": [
  {
    "name": "ohos.permission.INTERNET",
    "reason": "$string:network_reason",
    "usedScene": {
      "abilities": ["EntryAbility"],
      "when": "inuse"
    }
  }
]

ohos/entry/src/main/resources/base/element/string.json 中添加:

json 复制代码
{
  "name": "network_reason",
  "value": "使用网络访问外部链接"
}

三、核心 API 讲解

3.1 launchUrl - 启动 URL

dart 复制代码
Future<bool> launchUrl(
  Uri url, {
  LaunchMode mode = LaunchMode.platformDefault,
  WebViewConfiguration webViewConfiguration = const WebViewConfiguration(),
  String? webOnlyWindowName,
})

参数说明:

参数 类型 必填 默认值 说明
url Uri - 要打开的 URL
mode LaunchMode LaunchMode.platformDefault 打开模式
webViewConfiguration WebViewConfiguration 默认配置 WebView 配置(仅 inAppWebView 模式有效)
webOnlyWindowName String? null Web 平台窗口名称

返回值: Future<bool> - 成功返回 true,失败返回 false

3.2 LaunchMode 枚举

dart 复制代码
enum LaunchMode {
  platformDefault,           // 平台默认模式
  inAppWebView,              // 内置 WebView 模式
  externalApplication,       // 外部应用模式
  externalNonBrowserApplication,  // 外部非浏览器应用模式
}

各模式说明:

模式 说明 适用场景
platformDefault 平台默认行为,网页使用内置 WebView,其他使用外部应用 通用场景
inAppWebView 在应用内 WebView 中打开网页 需要保持在应用内的网页浏览
externalApplication 使用系统默认应用打开 URL 浏览器、邮件、电话等
externalNonBrowserApplication 使用非浏览器应用打开(iOS 10+) 打开其他应用

3.3 WebViewConfiguration 类

dart 复制代码
const WebViewConfiguration({
  this.enableJavaScript = true,
  this.enableDomStorage = true,
  this.headers = const <String, String>{},
})

参数说明:

参数 类型 必填 默认值 说明
enableJavaScript bool true 是否启用 JavaScript
enableDomStorage bool true 是否启用 DOM 存储
headers Map<String, String> {} 自定义请求头

3.4 canLaunchUrl - 检查 URL 是否可用

dart 复制代码
Future<bool> canLaunchUrl(Uri url)

说明: 检查设备上是否有应用可以处理指定的 URL。

返回值: Future<bool> - 有可用应用返回 true,否则返回 false

注意:

  • 在 Android 和 iOS 上,需要配置查询权限才能正常工作
  • 在 Web 平台上,除了 http(s) 外,其他 scheme 通常返回 false

3.5 closeInAppWebView - 关闭内置 WebView

dart 复制代码
Future<void> closeInAppWebView()

说明: 关闭之前通过 launchUrl 打开的内置 WebView。

注意: 只有在使用 LaunchMode.inAppWebView 模式打开网页后,此方法才有效。

Link 是一个可点击的链接组件,类似于 HTML 中的 <a> 标签。

dart 复制代码
Link(
  uri: Uri.parse('https://flutter.dev'),
  target: LinkTarget.defaultTarget,
  builder: (BuildContext context, FollowLink? followLink) {
    return ElevatedButton(
      onPressed: followLink,
      child: const Text('点击打开链接'),
    );
  },
)

参数说明:

参数 类型 必填 默认值 说明
uri Uri? - 链接目标 URL
target LinkTarget LinkTarget.defaultTarget 链接打开目标
builder LinkWidgetBuilder - 构建链接 UI 的回调

LinkTarget 枚举:

dart 复制代码
enum LinkTarget {
  defaultTarget,  // 平台默认
  self,           // 在当前窗口打开(使用 WebView)
  blank,          // 在新窗口打开
}

四、完整使用示例

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

void main() {
  runApp(const UrlLauncherApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '链接跳转中心',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFFF6B35)),
        useMaterial3: true,
      ),
      home: const UrlLauncherHomePage(),
    );
  }
}

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

  @override
  State<UrlLauncherHomePage> createState() => _UrlLauncherHomePageState();
}

class _UrlLauncherHomePageState extends State<UrlLauncherHomePage> {
  final TextEditingController _urlController = TextEditingController();
  bool _isWebViewMode = false;
  String _lastAction = '';

  @override
  void dispose() {
    _urlController.dispose();
    super.dispose();
  }

  Future<void> _launchUrl(String url, {LaunchMode mode = LaunchMode.externalApplication}) async {
    final Uri uri = Uri.parse(url);
    
    if (!await canLaunchUrl(uri)) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('无法打开链接: $url'),
            backgroundColor: Colors.red,
          ),
        );
      }
      return;
    }

    try {
      final bool launched = await launchUrl(
        uri,
        mode: mode,
        webViewConfiguration: const WebViewConfiguration(
          enableJavaScript: true,
          enableDomStorage: true,
        ),
      );

      if (mounted) {
        if (launched) {
          setState(() {
            _lastAction = '已打开: $url';
          });
        } else {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text('打开链接失败: $url'),
              backgroundColor: Colors.orange,
            ),
          );
        }
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('错误: $e'),
            backgroundColor: Colors.red,
          ),
        );
      }
    }
  }

  Future<void> _launchCustomUrl() async {
    String url = _urlController.text.trim();
    if (url.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('请输入 URL'),
          backgroundColor: Colors.orange,
        ),
      );
      return;
    }

    if (!url.startsWith('http://') && !url.startsWith('https://') &&
        !url.startsWith('tel:') && !url.startsWith('mailto:') &&
        !url.startsWith('sms:')) {
      url = 'https://$url';
    }

    final mode = _isWebViewMode ? LaunchMode.inAppWebView : LaunchMode.externalApplication;
    await _launchUrl(url, mode: mode);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('链接跳转中心'),
        backgroundColor: const Color(0xFFFF6B35),
        foregroundColor: Colors.white,
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          _buildSectionTitle('网页浏览'),
          _buildUrlInputCard(),
          const SizedBox(height: 8),
          _buildQuickLinkCard(
            icon: Icons.play_circle_outline,
            title: '哔哩哔哩',
            subtitle: '打开 B站移动版',
            url: 'https://m.bilibili.com',
            color: const Color(0xFFFB7299),
          ),
          _buildQuickLinkCard(
            icon: Icons.code,
            title: 'CSDN',
            subtitle: '打开 CSDN 移动版',
            url: 'https://m.csdn.net',
            color: const Color(0xFFFF6633),
          ),
          _buildQuickLinkCard(
            icon: Icons.live_tv,
            title: '虎牙直播',
            subtitle: '打开虎牙移动版',
            url: 'https://m.huya.com',
            color: const Color(0xFFFF4500),
          ),
          _buildQuickLinkCard(
            icon: Icons.search,
            title: '百度',
            subtitle: '打开百度移动版',
            url: 'https://m.baidu.com',
            color: const Color(0xFF306CFF),
          ),
          
          const SizedBox(height: 24),
          _buildSectionTitle('通讯功能'),
          _buildActionCard(
            icon: Icons.phone,
            title: '拨打电话',
            subtitle: '拨打客服电话',
            color: const Color(0xFF4CAF50),
            onTap: () => _launchUrl('tel:+8613800138000'),
          ),
          _buildActionCard(
            icon: Icons.email,
            title: '发送邮件',
            subtitle: '发送邮件到客服邮箱',
            color: const Color(0xFF2196F3),
            onTap: () => _launchUrl('mailto:support@example.com?subject=咨询&body=您好,'),
          ),
          _buildActionCard(
            icon: Icons.sms,
            title: '发送短信',
            subtitle: '发送短信到客服号码',
            color: const Color(0xFFFF9800),
            onTap: () => _launchUrl('sms:+8613800138000?body=您好,我需要帮助'),
          ),
          
          const SizedBox(height: 24),
          _buildSectionTitle('其他功能'),
          _buildActionCard(
            icon: Icons.map,
            title: '地图导航',
            subtitle: '打开地图查看位置',
            color: const Color(0xFF9C27B0),
            onTap: () => _launchUrl('https://uri.amap.com/marker?position=116.4074,39.9042&name=北京市&callnative=1'),
          ),
          _buildActionCard(
            icon: Icons.language,
            title: 'Flutter 官网',
            subtitle: '使用内置 WebView 打开',
            color: const Color(0xFF00B4FF),
            onTap: () => _launchUrl(
              'https://flutter.dev',
              mode: LaunchMode.inAppWebView,
            ),
          ),
          
          if (_lastAction.isNotEmpty) ...[
            const SizedBox(height: 24),
            _buildLastActionCard(),
          ],
          
          const SizedBox(height: 32),
        ],
      ),
    );
  }

  Widget _buildSectionTitle(String title) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 12),
      child: Text(
        title,
        style: const TextStyle(
          fontSize: 18,
          fontWeight: FontWeight.bold,
          color: Color(0xFFFF6B35),
        ),
      ),
    );
  }

  Widget _buildUrlInputCard() {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text(
              '自定义 URL',
              style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
            ),
            const SizedBox(height: 12),
            TextField(
              controller: _urlController,
              decoration: InputDecoration(
                hintText: '输入网址,如 bilibili.com',
                prefixIcon: const Icon(Icons.link),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(12),
                ),
                filled: true,
                fillColor: Colors.grey[50],
              ),
              onSubmitted: (_) => _launchCustomUrl(),
            ),
            const SizedBox(height: 12),
            Row(
              children: [
                const Text('打开模式:'),
                Expanded(
                  child: SegmentedButton<bool>(
                    segments: const [
                      ButtonSegment<bool>(
                        value: false,
                        label: Text('外部应用'),
                        icon: Icon(Icons.open_in_new),
                      ),
                      ButtonSegment<bool>(
                        value: true,
                        label: Text('内置 WebView'),
                        icon: Icon(Icons.web),
                      ),
                    ],
                    selected: {_isWebViewMode},
                    onSelectionChanged: (Set<bool> selected) {
                      setState(() {
                        _isWebViewMode = selected.first;
                      });
                    },
                  ),
                ),
              ],
            ),
            const SizedBox(height: 12),
            SizedBox(
              width: double.infinity,
              child: ElevatedButton.icon(
                onPressed: _launchCustomUrl,
                icon: const Icon(Icons.open_in_browser, color: Colors.white),
                label: const Text('打开链接'),
                style: ElevatedButton.styleFrom(
                  backgroundColor: const Color(0xFFFF6B35),
                  foregroundColor: Colors.white,
                  padding: const EdgeInsets.symmetric(vertical: 14),
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(12),
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildQuickLinkCard({
    required IconData icon,
    required String title,
    required String subtitle,
    required String url,
    required Color color,
  }) {
    return Card(
      child: ListTile(
        leading: CircleAvatar(
          backgroundColor: color.withOpacity(0.1),
          child: Icon(icon, color: color),
        ),
        title: Text(title),
        subtitle: Text(subtitle),
        trailing: const Icon(Icons.chevron_right),
        onTap: () => _launchUrl(url),
      ),
    );
  }

  Widget _buildActionCard({
    required IconData icon,
    required String title,
    required String subtitle,
    required Color color,
    required VoidCallback onTap,
  }) {
    return Card(
      child: ListTile(
        leading: CircleAvatar(
          backgroundColor: color.withOpacity(0.1),
          child: Icon(icon, color: color),
        ),
        title: Text(title),
        subtitle: Text(subtitle),
        trailing: const Icon(Icons.chevron_right),
        onTap: onTap,
      ),
    );
  }

  Widget _buildLastActionCard() {
    return Card(
      color: Colors.green[50],
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          children: [
            const Icon(Icons.check_circle, color: Colors.green),
            const SizedBox(width: 12),
            Expanded(
              child: Text(
                _lastAction,
                style: const TextStyle(color: Colors.green),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

五、适配要点

5.1 OpenHarmony 平台特性

  1. URL Scheme 支持

    • OpenHarmony 平台支持常见的 URL Scheme(http、https、tel、mailto、sms 等)
    • 系统会自动识别 URL 类型并调用相应的应用处理
  2. WebView 模式

    • LaunchMode.inAppWebView 在 OpenHarmony 平台完全支持
    • 内置 WebView 会保持在应用内,不会跳转到外部浏览器
    • 可以通过 closeInAppWebView() 关闭内置 WebView
  3. 权限处理

    • 打开网页需要网络权限
    • 拨打电话、发送邮件等功能需要系统支持
    • 部分功能可能需要用户在系统设置中授权
  4. URL 检查

    • 使用 canLaunchUrl() 可以预先检查 URL 是否可用
    • 建议在调用 launchUrl() 前先进行检查

5.2 与 Android/iOS 的差异

差异点 Android/iOS OpenHarmony
URL Scheme 支持 完整支持 支持常见 Scheme
内置 WebView 完整支持 支持
应用间跳转 支持 支持,但需要系统配置
查询权限配置 需要配置 queries 无需特殊配置
邮件客户端 多个可选 取决于系统安装的应用
地图应用 Google Maps/Apple Maps 系统地图应用

5.3 注意事项

  1. URL 格式

    • 确保 URL 包含正确的 Scheme(http://、https://、tel: 等)
    • 用户输入的 URL 可能需要自动补全 Scheme
  2. 错误处理

    • launchUrl() 可能返回 false 或抛出异常
    • 建议使用 try-catch 包裹调用代码
    • 提供友好的错误提示给用户
  3. 用户体验

    • 使用 canLaunchUrl() 预先检查可避免无效调用
    • 对于重要操作,提供确认对话框
    • 内置 WebView 模式适合需要保持在应用内的场景
  4. 安全性

    • 不要打开不可信的 URL
    • 对于用户输入的 URL 进行验证
    • 敏感操作建议使用 HTTPS

六、常见问题

Q1: launchUrl 返回 false 怎么办?

原因: 没有应用可以处理该 URL 或权限不足。

解决方案:

dart 复制代码
final Uri uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
  await launchUrl(uri);
} else {
  // 提示用户没有可用的应用
  ScaffoldMessenger.of(context).showSnackBar(
    const SnackBar(content: Text('没有可用的应用处理此链接')),
  );
}

Q2: 如何区分内置 WebView 和外部浏览器?

解决方案:

dart 复制代码
// 内置 WebView(保持在应用内)
await launchUrl(
  Uri.parse('https://example.com'),
  mode: LaunchMode.inAppWebView,
);

// 外部浏览器(跳转到系统浏览器)
await launchUrl(
  Uri.parse('https://example.com'),
  mode: LaunchMode.externalApplication,
);

Q3: 如何关闭内置 WebView?

解决方案:

dart 复制代码
// 在适当的时候调用
await closeInAppWebView();

// 例如:在返回按钮中
AppBar(
  leading: IconButton(
    icon: const Icon(Icons.close),
    onPressed: () async {
      await closeInAppWebView();
      Navigator.pop(context);
    },
  ),
)

Q4: 如何构建完整的邮件 URL?

解决方案:

dart 复制代码
// 基本格式
final Uri emailUri = Uri(
  scheme: 'mailto',
  path: 'user@example.com',
  query: 'subject=邮件主题&body=邮件正文',
);

await launchUrl(emailUri);

// 或者使用字符串
await launchUrl(Uri.parse('mailto:user@example.com?subject=主题&body=正文'));

Q5: 如何构建完整的电话 URL?

解决方案:

dart 复制代码
// 基本格式
final Uri phoneUri = Uri(
  scheme: 'tel',
  path: '+8613800138000',
);

await launchUrl(phoneUri);

// 或者使用字符串
await launchUrl(Uri.parse('tel:+8613800138000'));

Q8: geo: 协议无法打开怎么办?

原因: OpenHarmony 平台可能不支持 geo: 协议直接调用地图应用。

解决方案: 使用地图服务的 Web URL 代替:

dart 复制代码
// 使用高德地图 Web URL
await launchUrl(
  Uri.parse('https://uri.amap.com/marker?position=116.4074,39.9042&name=北京市&callnative=1'),
);

// 使用百度地图 Web URL
await launchUrl(
  Uri.parse('https://api.map.baidu.com/marker?location=39.9042,116.4074&title=北京市'),
);

// 这些 URL 会在浏览器中打开,并提供跳转到原生地图的选项

解决方案:

dart 复制代码
Link(
  uri: Uri.parse('https://flutter.dev'),
  builder: (BuildContext context, FollowLink? followLink) {
    return TextButton(
      onPressed: followLink,
      child: const Text('访问 Flutter 官网'),
    );
  },
)

Q7: 如何处理用户输入的 URL?

解决方案:

dart 复制代码
Future<void> launchUserInput(String input) async {
  String url = input.trim();
  
  // 自动补全 Scheme
  if (!url.startsWith('http://') && 
      !url.startsWith('https://') &&
      !url.startsWith('tel:') &&
      !url.startsWith('mailto:')) {
    url = 'https://$url';
  }
  
  final Uri uri = Uri.parse(url);
  
  if (await canLaunchUrl(uri)) {
    await launchUrl(uri);
  } else {
    // 处理错误
    print('无法打开: $url');
  }
}

七、总结

url_launcher 是 Flutter 生态中最常用的 URL 启动器插件,在 OpenHarmony 平台的适配已经非常成熟。本文内容:

  1. url_launcher 的核心 API 和使用方法
  2. LaunchMode 的各种模式及其适用场景
  3. WebViewConfiguration 的配置选项
  4. Link 组件的使用方法
  5. 完整的应用级别链接跳转中心实现
  6. OpenHarmony 平台的权限配置和适配要点
  7. 常见问题和解决方案

在实际开发中,建议根据具体需求选择合适的 LaunchMode,并使用 canLaunchUrl() 进行预先检查。对于需要保持在应用内的网页浏览场景,使用 inAppWebView 模式;对于需要调用系统功能的场景(如拨号、发邮件),使用 externalApplication 模式。

💡 提示: 更多 OpenHarmony 适配的 Flutter 三方库信息,请访问 开源鸿蒙跨平台开发者社区 获取最新资源和技术支持。

相关推荐
天渺工作室3 小时前
Flutter 版的 NVM——FVM 使用指南
flutter·dart
Lanren的编程日记13 小时前
Flutter鸿蒙应用开发:生物识别(指纹/面容)功能集成实战
flutter·华为·harmonyos
Lanren的编程日记17 小时前
Flutter鸿蒙应用开发:基础UI组件库设计与实现实战
flutter·ui·harmonyos
西西学代码17 小时前
Flutter---波形动画
flutter
于慨20 小时前
flutter基础组件用法
开发语言·javascript·flutter
恋猫de小郭1 天前
Android CLI ,谷歌为 Android 开发者专研的 AI Agent,提速三倍
android·前端·flutter
火柴就是我1 天前
flutter pushAndRemoveUntil 的一次小疑惑
flutter
于慨1 天前
flutter doctor问题解决
flutter
唔661 天前
flutter 图片加载类 图片的安全使用
安全·flutter