Flutter NFC链接支持插件uni_links_nfc_support的使用

Flutter NFC链接支持插件uni_links_nfc_support的使用

在现有的url_links插件基础上,创建了一个新的插件,名为uni_links_nfc_support。该插件参考了CathalT的NFC Support代码,增加了通过NFC标签进行App/Deep Links的支持。

这是一个帮助实现App/Deep Links(Android)以及Universal Links和自定义URL方案(iOS)的Flutter插件项目。

这些链接就像浏览器链接一样,可以激活您的应用,并可能包含用于加载应用程序特定部分或从网站(或其他应用)继续用户活动的信息。

App Links和Universal Links是常规的https链接,因此如果应用未安装(或未正确设置),它们将在浏览器中打开,允许您展示网页以供进一步操作,例如安装应用。

确保您详细阅读安装和使用指南,尤其是对于App/Universal Links(https方案)。

安装

要使用该插件,在您的pubspec.yaml文件中添加uni_links作为依赖:

dependencies:
  uni_links: ^0.5.0

由于迁移至空安全,一些API发生了变化。这些变化主要涉及函数转变为属性,类型变得显式可为空。示例包中的更改是一个升级到此版本的好例子。

权限

Android和iOS都需要在配置文件中声明链接权限。

您可以查看示例目录中的示例应用以了解Deep Links(Android)和Custom URL schemes(iOS)。

以下步骤不是Flutter特定的,而是平台特定的。您可以通过搜索有关Android上的App Links或Deep Links以及iOS上的Universal Links或自定义URL方案来找到更详细的指南。

对于Android

uni_links支持两种类型的Android链接:“App Links” 和 “Deep Links”。

  • App Links仅适用于https方案并需要指定主机以及托管文件assetlinks.json。请参阅下面的指南。
  • Deep Links可以具有任何自定义方案,不需要主机,也不需要托管文件。缺点是任何应用都可以声明一个方案+主机组合,因此请确保您的方案尽可能唯一,例如HST0000001://host.com

您需要在android/app/src/main/AndroidManifest.xml中至少声明一种意图过滤器:

<manifest ...>
  ...
  <application ...>
    <activity ...>
      ...
      <!-- Deep Links -->
      <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <!-- 接受以 YOUR_SCHEME://YOUR_HOST 开头的URI -->
        <data
          android:scheme="[YOUR_SCHEME]"
          android:host="[YOUR_HOST]" />
      </intent-filter>

      <!-- App Links -->
      <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <!-- 接受以 https://YOUR_HOST 开头的URI -->
        <data
          android:scheme="https"
          android:host="[YOUR_HOST]" />
      </intent-filter>
    </activity>
  </application>
</manifest>

android:host属性对Deep Links是可选的。

为了进一步提高特定性,您可以添加android:pathPrefix属性:

<!-- 接受以 YOUR_SCHEME://YOUR_HOST/NAME/NAME... 开头的URI -->
<!-- 接受以 https://YOUR_HOST/NAME/NAME... 开头的URI -->
<!-- 注意前导斜杠是必需的 -->
<data
  android:scheme="[YOUR_SCHEME_OR_HTTPS]"
  android:host="[YOUR_HOST]"
  android:pathPrefix="/[NAME][/NAME...]" />

更多信息请参阅:

Android开发者文档也是关于Deep Links和App Links的宝贵信息来源。

对于iOS

iOS有两种类型的链接:“Universal Links” 和 “Custom URL schemes”。

  • Universal Links仅适用于https方案并需要指定主机、授权和托管文件apple-app-site-association。请参阅下面的指南。
  • Custom URL schemes可以有任意自定义方案,没有主机特定性,也没有授权或托管文件。缺点是任何应用都可以声明任何方案,因此请确保您的方案尽可能唯一,例如hst0000001myIncrediblyAwesomeScheme

您需要声明其中一种。


对于Universal Links,您需要添加或创建一个com.apple.developer.associated-domains授权——要么通过Xcode(见下文),要么通过编辑(或创建并添加到Xcode)ios/Runner/Runner.entitlements文件。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <!-- ... 其他键 -->
  <key>com.apple.developer.associated-domains</key>
  <array>
    <string>applinks:[YOUR_HOST]</string>
  </array>
  <!-- ... 其他键 -->
</dict>
</plist>

这允许您的应用从https://YOUR_HOST链接启动。

在Xcode中创建授权文件

  1. 双击打开ios/Runner.xcworkspace文件。
  2. 转到项目导航器(Cmd+1),选择顶部的Runner根项目项。
  3. 选择Runner目标,然后选择Signing & Capabilities选项卡。
  4. 点击+ Capability(加号)按钮添加新功能。
  5. 输入“associated domains”,然后选择该项目。
  6. 双击列表中的第一项,将其从webcredentials:example.com更改为:applinks: + 您的主机(例如 my-fancy-domain.com)。
  7. 将创建一个名为Runner.entitlements的文件并添加到项目中。
  8. 完成。这里有一张截图

更多信息,请参阅Apple的Universal Links指南。


对于Custom URL schemes,您需要在ios/Runner/Info.plist中声明方案(或者通过Xcode的目标信息编辑器,在URL Types下):

<?xml ...?>
<!-- ... 其他标签 -->
<plist>
<dict>
  <!-- ... 其他标签 -->
  <key>CFBundleURLTypes</key>
  <array>
    <dict>
      <key>CFBundleTypeRole</key>
      <string>Editor</string>
      <key>CFBundleURLName</key>
      <string>[ANY_URL_NAME]</string>
      <key>CFBundleURLSchemes</key>
      <array>
        <string>[YOUR_SCHEME]</string>
      </array>
    </dict>
  </array>
  <!-- ... 其他标签 -->
</dict>
</plist>

这允许您的应用从YOUR_SCHEME://ANYTHING链接启动。

更多信息,请参阅Apple的Inter-App Communication指南。

强烈建议观看Apple WWDC 2015, session 509 - Seamless Linking to Your App,以理解Universal Links的工作原理(及其设置)。

使用

您的应用将通过两种方式接收链接:冷启动和从后台唤醒。更多细节请参阅更多关于从链接启动应用

注意:getInitialLink/getInitialUri应该在应用生命周期中仅处理一次,因为它的值在整个应用生命周期内不会改变。

初始链接(字符串)

返回应用启动时的链接(如果有)。

您应该在应用生命周期早期处理它,并且仅处理一次。尽管您可以多次读取其值,但仅处理一次即可。

import 'dart:async';
import 'dart:io';

import 'package:uni_links/uni_links.dart';
import 'package:flutter/services.dart' show PlatformException;

// ...

Future<void> initUniLinks() async {
  // 平台消息可能会失败,所以我们使用try/catch PlatformException。
  try {
    final initialLink = await getInitialLink();
    // 解析链接并警告用户,如果链接不正确,但请注意它可能是 `null`。
  } on PlatformException {
    // 处理异常,警告用户他们的操作没有成功
    // return?
  }
}

// ...

初始链接(Uri)

getInitialLink相同,但转换为Uri对象。

注意:您应该在应用生命周期早期处理它,并且仅处理一次。

// Uri解析可能会失败,所以我们使用try/catch FormatException。
try {
  final initialUri = await getInitialUri();
  // 使用Uri并警告用户,如果链接不正确,但请注意它可能是 `null`。
} on FormatException {
  // 处理异常,警告用户他们的操作没有成功
  // return?
}
// ... 其他异常处理如PlatformException

可以通过使用Uri.parse(initialLink)达到同样的效果,这是这个方便方法所做的。

改变事件(字符串)

通常您会检查getInitialLink并监听更改。

import 'dart:async';
import 'dart:io';

import 'package:uni_links/uni_links.dart';

// ...

StreamSubscription? _sub;

Future<void> initUniLinks() async {
  // ... 检查initialLink

  // 附加一个流监听器
  _sub = linkStream.listen((String? link) {
    // 解析链接并警告用户,如果链接不正确
  }, onError: (err) {
    // 处理异常,警告用户他们的操作没有成功
  });

  // 注意:不要忘记在dispose()中调用 _sub.cancel()
}

// ...

改变事件(Uri)

linkStream相同,但转换为发出Uri对象。

通常您会检查getInitialUri并监听更改。

import 'dart:async';
import 'dart:io';

import 'package:uni_links/uni_links.dart';

// ...

StreamSubscription? _sub;

Future<void> initUniLinks() async {
  // ... 检查initialUri

  // 附加一个流监听器
  _sub = uriLinkStream.listen((Uri? uri) {
    // 使用Uri并警告用户,如果链接不正确
  }, onError: (err) {
    // 处理异常,警告用户他们的操作没有成功
  });

  // 注意:不要忘记在dispose()中调用 _sub.cancel()
}

// ...

更多关于从链接启动应用

如果应用被终止(或不在后台运行)并且操作系统必须重新启动它——这是一种冷启动。在这种情况下,getInitialLink将具有启动应用的链接,而流不会产生链接(此时)。

或者——如果应用正在后台运行并且操作系统必须将其带到前台,则流将是产生链接的一方,而getInitialLink将为null,或初始链接,应用就是从该链接启动的。

由于这两种情况,您应该始终添加一个检查初始链接(或URI)的条件,并订阅链接(或URI)流。

链接工具

如果您注册了一个模式,比如说unilink,您可以使用这些命令行工具:

Android

假设您已经安装了Android Studio(带有SDK平台工具):

adb shell 'am start -W -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "unilinks://host/path/subpath"'
adb shell 'am start -W -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "unilinks://example.com/path/portion/?uid=123&token=abc"'
adb shell 'am start -W -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "unilinks://example.com/?arr%5b%5d=123&arr%5b%5d=abc&addr=1%20Nowhere%20Rd&addr=Rand%20City%F0%9F%98%82"'
adb shell 'am start -W -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "unilinks://@@malformed.invalid.url/path?"'

如果您没有adb(但是有$ANDROID_HOME环境变量),则可以使用"$ANDROID_HOME"/platform-tools/adb ...

注意:您也可以直接进入adb shell并在其中运行am命令。

注意:我使用单引号,因为后面跟shell命令的是将在模拟器(或设备)上运行的内容,shell元字符(如问号?和与号&)通常在您的shell中有不同的含义。

adb shell与唯一的可用设备(或模拟器)通信,所以如果您有多台设备,您必须指定要运行shell的设备:

  • 唯一的USB连接设备 - adb -d shell '...'
  • 唯一的模拟设备 - adb -e shell '...'

您可以使用adb devices列出当前可用的设备(同样flutter devices也可以完成同样的工作)。

iOS

假设您已经安装了Xcode:

/usr/bin/xcrun simctl openurl booted "unilinks://host/path/subpath"
/usr/bin/xcrun simctl openurl booted "unilinks://example.com/path/portion/?uid=123&token=abc"
/usr/bin/xcrun simctl openurl booted "unilinks://example.com/?arr%5b%5d=123&arr%5b%5d=abc&addr=1%20Nowhere%20Rd&addr=Rand%20City%F0%9F%98%82"
/usr/bin/xcrun simctl openurl booted "unilinks://@@malformed.invalid.url/path?"

如果您有xcrun(或simctl)在您的路径中,可以直接调用它。

标志booted假设有一个已打开的模拟器(您可以通过open -a Simulator启动),其中有一个已启动的设备。您可以通过指定其UUID(通过xcrun simctl listflutter devices找到)替换booted标志来针对特定设备。

App Links或Universal Links

这些类型的链接使用https作为方案,因此您可以使用上述示例通过将unilinks替换为https来实现它们。

示例代码

下面是使用uni_links_nfc_support插件的示例代码:

import 'dart:async';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:uni_links_nfc_support/uni_links_nfc_support.dart';

bool _initialUriIsHandled = false;

void main() => runApp(MaterialApp(home: MyApp()));

class MyApp extends StatefulWidget {
  [@override](/user/override)
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> with SingleTickerProviderStateMixin {
  Uri? _initialUri;
  Uri? _latestUri;
  Object? _err;

  StreamSubscription? _sub;

  final _scaffoldKey = GlobalKey<ScaffoldState>();
  final _cmds = getCmds();
  final _cmdStyle = const TextStyle(
      fontFamily: 'Courier', fontSize: 12.0, fontWeight: FontWeight.w700);

  [@override](/user/override)
  void initState() {
    super.initState();
    _handleIncomingLinks();
    _handleInitialUri();
  }

  [@override](/user/override)
  void dispose() {
    _sub?.cancel();
    super.dispose();
  }

  /// Handle incoming links - the ones that the app will receive from the OS
  /// while already started.
  void _handleIncomingLinks() {
    if (!kIsWeb) {
      // It will handle app links while the app is already started - be it in
      // the foreground or in the background.
      _sub = uriLinkStream.listen((Uri? uri) {
        if (!mounted) return;
        print('got uri: $uri');
        setState(() {
          _latestUri = uri;
          _err = null;
        });
      }, onError: (Object err) {
        if (!mounted) return;
        print('got err: $err');
        setState(() {
          _latestUri = null;
          if (err is FormatException) {
            _err = err;
          } else {
            _err = null;
          }
        });
      });
    }
  }

  /// Handle the initial Uri - the one the app was started with
  ///
  /// **ATTENTION**: `getInitialLink`/`getInitialUri` should be handled
  /// ONLY ONCE in your app's lifetime, since it is not meant to change
  /// throughout your app's life.
  ///
  /// We handle all exceptions, since it is called from initState.
  Future<void> _handleInitialUri() async {
    // In this example app this is an almost useless guard, but it is here to
    // show we are not going to call getInitialUri multiple times, even if this
    // was a widget that will be disposed of (ex. a navigation route change).
    if (!_initialUriIsHandled) {
      _initialUriIsHandled = true;
      _showSnackBar('_handleInitialUri called');
      try {
        final uri = await getInitialUri();
        if (uri == null) {
          print('no initial uri');
        } else {
          print('got initial uri: $uri');
        }
        if (!mounted) return;
        setState(() => _initialUri = uri);
      } on PlatformException {
        // Platform messages may fail but we ignore the exception
        print('failed to get initial uri');
      } on FormatException catch (err) {
        if (!mounted) return;
        print('malformed initial uri');
        setState(() => _err = err);
      }
    }
  }

  [@override](/user/override)
  Widget build(BuildContext context) {
    final queryParams = _latestUri?.queryParametersAll.entries.toList();

    return Scaffold(
      key: _scaffoldKey,
      appBar: AppBar(
        title: const Text('uni_links example app'),
      ),
      body: ListView(
        shrinkWrap: true,
        padding: const EdgeInsets.all(8.0),
        children: [
          if (_err != null)
            ListTile(
              title: const Text('Error', style: TextStyle(color: Colors.red)),
              subtitle: Text('$_err'),
            ),
          ListTile(
            title: const Text('Initial Uri'),
            subtitle: Text('$_initialUri'),
          ),
          if (!kIsWeb) ...[
            ListTile(
              title: const Text('Latest Uri'),
              subtitle: Text('$_latestUri'),
            ),
            ListTile(
              title: const Text('Latest Uri (path)'),
              subtitle: Text('${_latestUri?.path}'),
            ),
            ExpansionTile(
              initiallyExpanded: true,
              title: const Text('Latest Uri (query parameters)'),
              children: queryParams == null
                  ? const [ListTile(dense: true, title: Text('null'))]
                  : [
                      for (final item in queryParams)
                        ListTile(
                          title: Text(item.key),
                          trailing: Text(item.value.join(', ')),
                        )
                    ],
            ),
          ],
          _cmdsCard(_cmds),
          const Divider(),
          if (!kIsWeb)
            ListTile(
              leading: const Icon(Icons.error, color: Colors.red),
              title: const Text(
                'Force quit this example app',
                style: TextStyle(color: Colors.red),
              ),
              onTap: () {
                // WARNING: DO NOT USE this in production !!!
                //          Your app will (most probably) be rejected !!!
                if (Platform.isIOS) {
                  exit(0);
                } else {
                  SystemNavigator.pop();
                }
              },
            ),
        ],
      ),
    );
  }

  Widget _cmdsCard(List<String>? commands) {
    Widget platformCmds;

    if (commands == null) {
      platformCmds = const Center(child: Text('Unsupported platform'));
    } else {
      platformCmds = Column(
        children: [
          const [
            if (kIsWeb)
              Text('Append this path to the Web app\'s URL, replacing `#/`:\n')
            else
              Text('To populate above fields open a terminal shell and run:\n'),
          ],
          intersperse(
              commands.map<Widget>((cmd) => InkWell(
                    onTap: () => _printAndCopy(cmd),
                    child: Text('\n$cmd\n', style: _cmdStyle),
                  )),
              const Text('or')),
          [
            Text(
              '(tap on any of the above commands to print it to'
              ' the console/logger and copy to the device clipboard.)',
              textAlign: TextAlign.center,
              style: Theme.of(context).textTheme.caption,
            ),
          ]
        ].expand((el) => el).toList(),
      );
    }

    return Card(
      margin: const EdgeInsets.only(top: 20.0),
      child: Padding(
        padding: const EdgeInsets.all(10.0),
        child: platformCmds,
      ),
    );
  }

  Future<void> _printAndCopy(String cmd) async {
    print(cmd);

    await Clipboard.setData(ClipboardData(text: cmd));
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Copied to Clipboard')),
    );
  }

  void _showSnackBar(String msg) {
    WidgetsBinding.instance?.addPostFrameCallback((_) {
      final context = _scaffoldKey.currentContext;
      if (context != null) {
        ScaffoldMessenger.of(context).showSnackBar(SnackBar(
          content: Text(msg),
        ));
      }
    });
  }
}

List<String>? getCmds() {
  late final String cmd;
  var cmdSuffix = '';

  const plainPath = 'path/subpath';
  const args = 'path/portion/?uid=123&token=abc';
  const emojiArgs =
      '?arr%5b%5d=123&arr%5b%5d=abc&addr=1%20Nowhere%20Rd&addr=Rand%20City%F0%9F%98%82';

  if (kIsWeb) {
    return [
      plainPath,
      args,
      emojiArgs,
      // Cannot create malformed url, since the browser will ensure it is valid
    ];
  }

  if (Platform.isIOS) {
    cmd = '/usr/bin/xcrun simctl openurl booted';
  } else if (Platform.isAndroid) {
    cmd = '\$ANDROID_HOME/platform-tools/adb shell \'am start'
        ' -a android.intent.action.VIEW'
        ' -c android.intent.category.BROWSABLE -d';
    cmdSuffix = '\'';
  } else {
    return null;
  }

  // https://orchid-forgery.glitch.me/mobile/redirect/
  return [
    '$cmd "unilinks://host/$plainPath"$cmdSuffix',
    '$cmd "unilinks://example.com/$args"$cmdSuffix',
    '$cmd "unilinks://example.com/$emojiArgs"$cmdSuffix',
    '$cmd "unilinks://@@malformed.invalid.url/path?"$cmdSuffix',
  ];
}

List<Widget> intersperse(Iterable<Widget> list, Widget item) {
  final initialValue = <Widget>[];
  return list.fold(initialValue, (all, el) {
    if (all.isNotEmpty) all.add(item);
    all.add(el);
    return all;
  });
}

更多关于Flutter NFC链接支持插件uni_links_nfc_support的使用的实战教程也可以访问 https://www.itying.com/category-92-b0.html

1 回复

更多关于Flutter NFC链接支持插件uni_links_nfc_support的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html


当然,下面是一个关于如何使用 uni_links_nfc_support 插件来在 Flutter 应用中实现 NFC 链接支持的代码案例。这个插件允许你的应用通过 NFC 标签接收和处理 NFC 数据。

首先,确保你的 Flutter 项目已经添加了 uni_links_nfc_support 插件。你可以在 pubspec.yaml 文件中添加以下依赖:

dependencies:
  flutter:
    sdk: flutter
  uni_links: ^0.5.1  # 确保uni_links也被添加,因为uni_links_nfc_support依赖于它
  uni_links_nfc_support: ^x.y.z  # 使用最新版本号替换x.y.z

然后运行 flutter pub get 来获取依赖。

接下来,你需要在 Android 和 iOS 上进行一些配置,以确保 NFC 功能可用。不过,由于 uni_links_nfc_support 插件已经处理了很多底层配置,你可能只需要确保在 AndroidManifest.xml 中添加 NFC 权限(如果插件文档没有说明这一点,则通常不需要手动添加,因为插件已经处理了):

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.nfc" android:required="true" />

然后,在你的 Flutter 应用中实现 NFC 链接的处理逻辑。以下是一个简单的例子:

import 'package:flutter/material.dart';
import 'package:uni_links/uni_links.dart';
import 'package:uni_links_nfc_support/uni_links_nfc_support.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter NFC Link Support Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  String _nfcData = 'No NFC data received';

  @override
  void initState() {
    super.initState();
    // 初始化uni_links监听器
    _initUniLinks();
    // 初始化NFC支持
    _initNfcSupport();
  }

  void _initUniLinks() {
    getLinksStream().listen((Uri? uri) {
      if (uri != null) {
        // 处理从NFC标签读取的URI
        setState(() {
          _nfcData = 'Received URI: ${uri.toString()}';
        });
      }
    }, onError: (error) {
      // 处理错误
      print('Error getting links: $error');
    });
  }

  void _initNfcSupport() {
    // 初始化NFC支持(具体方法取决于插件的实现,这里假设插件提供了初始化方法)
    // 注意:这个初始化方法是一个假设的方法,具体实现请参考插件文档
    NfcSupport.initialize().then((_) {
      print('NFC support initialized');
    }).catchError((error) {
      print('Failed to initialize NFC support: $error');
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Flutter NFC Link Support Demo'),
      ),
      body: Center(
        child: Text(_nfcData),
      ),
    );
  }
}

// 假设NfcSupport是一个提供的类,用于NFC支持初始化(实际上这个类和方法名取决于插件的具体实现)
class NfcSupport {
  static Future<void> initialize() async {
    // 这里应该是插件提供的初始化NFC支持的方法
    // 由于插件的具体实现未知,这里只是一个占位符
  }
}

注意

  1. NfcSupport 类及其 initialize 方法是假设的,因为 uni_links_nfc_support 插件的具体 API 可能会有所不同。你需要参考插件的官方文档来了解如何正确初始化 NFC 支持。
  2. NFC 功能需要在支持 NFC 的设备和操作系统上测试。
  3. 确保你的应用有适当的权限来处理 NFC 数据,并且在 Android 上,你可能需要在运行时请求 NFC 权限(尽管 uni_links_nfc_support 插件可能已经处理了这些权限请求)。

由于 uni_links_nfc_support 插件的具体实现和 API 可能会随着版本更新而变化,因此强烈建议查阅插件的官方文档和示例代码以获取最新和最准确的信息。

回到顶部