Flutter笔记:手写并发布一个人机滑动验证码插件
作者:李俊才 (jcLee95):https://blog.csdn.net/qq_28550263
邮箱 :291148484@163.com
本文地址:https://blog.csdn.net/qq_28550263/article/details/133529459
写 Flutter 项目时,遇到需要滑块验证码功能。滑块验证码属于人机验证码的一种,看起来像是在一个图片中“挖去”了一块,然后通过用户手动操作滑块,让被“挖去”的部分移回来。由于我不想使用各种第三方模块,因此决定自己实现一个初版以后慢慢添砖加瓦。本文是对第一个版本的一点记录。
在 Flutter 开发中,使用人机验证码(也称为 CAPTCHA,即 Completely Automated Public Turing test to tell Computers and Humans Apart)通常是为了增强应用程序的安全性和防止恶意活动。
防止自动化攻击:恶意用户和自动化脚本可以尝试大规模攻击应用程序,例如注册多个虚假帐户、暴力破解密码、滥发垃圾邮件或提交虚假表单。人机验证码可以帮助阻止这些自动化攻击,因为它们要求用户证明自己是真人而不是机器;
防止垃圾数据输入:人机验证码可以确保用户提交的数据是有效和真实的。例如,在用户注册过程中,验证码可以防止恶意用户自动化注册虚假帐户,从而保护应用程序的数据质量;
防止滥用资源:如果应用程序提供某种资源,如 API 访问或文件下载,希望防止单个用户或恶意机器人滥用这些资源。通过要求用户在访问这些资源之前进行验证码验证,可以限制滥用的可能性;
增加安全性:在某些情况下,用户可能需要进行敏感操作,如更改密码、恢复帐户或进行金融交易。在这些情况下,验证码可以提供额外的安全性,确保只有授权用户可以执行这些操作;
在 Flutter 中实施人机验证码通常涉及使用插件或集成第三方服务,这些服务提供了生成和验证验证码的功能。
总之,人机验证码是一种重要的安全措施,可帮助保护 Flutter 应用程序免受各种恶意活动的威胁,并提高用户数据的质量和应用程序的整体安全性。
- flutter pub: https://pub.dev/packages/jc_captcha
- git lab: http://thispage.tech:9680/jclee1995/flutter-jc-captcha
flutter pub add jc_captcha
import 'package:flutter/material.dart';
import 'package:jc_captcha/jc_captcha.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Captcha Plugin Example'),
),
body: CaptchaWidget(
imageUrl:
'http://thispage.tech:9680/jclee1995/flutter-jc-captcha/-/raw/master/example/test_picture.png',
onSuccess: () {
print('验证成功');
},
onFail: () {
print('验证失败');
},
),
),
);
}
}
说明:
验证码仅验证一次即失效:
验证码有两个状态,且状态该转换是一次性的,即:
未验证 =》 已验证
- 从未验证到已验证仅仅转换一次,转换结果有验证成功和验证失败;
- 如果验证成功,则执行成功回调 onSuccess;
- 如果验证失败,则执行失败回调 onFail;
效果如图所示:
抠出小块图是不是一定要从原图像那里拷贝一块像素?你当然可以这样来实现,不过会复杂一些。不过还有一种假设是,抠出的拼图部分不是抠出的,而是原原本本的一张图。对比而言:
原始图像处理: 加载原始验证码图像,该图像包括背景图像和一个需要滑动的小块图像。
拷贝小块图像: 使用Flutter的图像处理功能,将原始图像中的小块图像精确拷贝出来,并将其作为验证码的滑块。这可以通过裁剪原始图像的一部分来实现。
验证用户拖动: 当用户尝试拖动滑块时,需要检查滑块的位置是否与原始图像中的小块位置匹配。这可以涉及比较滑块的位置与小块的位置是否一致。
原始图像处理: 同样,加载原始验证码图像,包括背景图像和一个需要滑动的小块图像。
自定义布局: 使用Flutter的布局和图层叠加功能,将两张图像堆叠在一起,确保小块图像与背景图像的对齐。这可以通过使用
Stack
小部件或Positioned
小部件来实现。验证用户拖动: 当用户尝试拖动滑块时,需要检查滑块的位置是否与小块图像的位置匹配。这可以通过比较滑块的位置是否在小块图像的位置上来实现。
两种方案的选择取决于项目需求和个人偏好。我个人觉得方案2更加具有可操作性,因此我后续是基于这种方法来实现的。
这里对用到得一些 Flutter 基础知识做简单介绍,方便初学读者了解学习相关知识。
在Flutter中,堆叠布局(Stack布局)是一种常用的布局方式,用于将多个子部件叠加在一起。堆叠布局允许您将子部件以层叠的方式排列,每个子部件可以覆盖或部分覆盖其他子部件,从而创建复杂的布局效果。在Flutter中,堆叠布局由两个主要组件构成:
Stack(堆叠): Stack是一个容器小部件,用于包含子部件并按照它们的绘制顺序将它们叠加在一起。子部件按照从底部到顶部的顺序堆叠。您可以使用
children
属性来指定要叠加的子部件列表。Positioned(定位): Positioned小部件用于控制子部件在Stack中的位置。它允许您指定子部件的左、上、右和下边距,从而将子部件精确定位在Stack上。
在堆叠布局中,子部件的位置和大小是通过Positioned小部件来控制的。每个Positioned小部件都必须包含一个左、上、右和下的属性,以确定子部件在Stack中的位置和大小。
left
:指定子部件的左边距。top
:指定子部件的上边距。right
:指定子部件的右边距。bottom
:指定子部件的下边距。
这些属性可以设置为null
,以便自动确定位置,或者设置为具体的值,以确保子部件在Stack中的精确定位。
例如,下面的代码展示了如何将两个容器叠加在一起:
Stack(
alignment: Alignment.center,
children: [
Container(
width: 200,
height: 200,
color: Colors.blue,
),
Positioned(
left: 50,
top: 50,
child: Container(
width: 100,
height: 100,
color: Colors.red,
),
),
],
)
在上述示例中,Stack包含两个容器,一个蓝色的大容器和一个红色的小容器。通过Positioned小部件,我们将小容器定位到了大容器的左上角。
Flutter的Slider(滑块)组件是一个用于选择一个范围内数值的交互式控件。用户可以通过滑动滑块来选择数值,这使得它在用户界面中用于调整设置和选择数值非常有用。
Slider组件主要有以下功能:
数值范围选择: Slider允许用户在指定的数值范围内进行选择。用户可以通过滑动滑块来选择一个数值,该数值通常表示某种设置或参数。
分割刻度: Slider可以显示刻度线,并且可以根据需要进行分割。这些刻度线使用户更容易准确地选择所需的数值。
标签显示: Slider可以显示当前数值的标签,通常位于滑块上方或下方。这有助于用户了解所选择的数值。
回调函数: 您可以为Slider设置一个回调函数,当用户拖动滑块时,会触发该函数。这使得您可以在数值发生变化时执行特定的操作,如更新UI或应用程序状态。
自定义样式: Slider具有丰富的自定义样式选项,您可以更改滑块、轨道和刻度的颜色、形状和大小,以适应您的应用程序设计。
以下是Flutter Slider组件的一些常用属性:
value
:表示当前的数值,可以通过设置此值来控制Slider的位置。onChanged
:一个回调函数,当用户拖动滑块时触发,用于处理数值的变化。min
:Slider的最小值。max
:Slider的最大值。divisions
:用于将Slider轨道分割为多少个离散步骤,通常与滑块刻度一起使用。label
:显示在滑块上方或下方的标签,通常用于显示当前值。activeColor
:激活状态下(滑块被拖动)的颜色。inactiveColor
:非激活状态下的颜色。thumbColor
:滑块的颜色。thumbShape
:滑块的形状,可以是圆形、方形等。trackHeight
:轨道的高度。
例如:
class SliderExample extends StatefulWidget {
const SliderExample({super.key});
@override
State<SliderExample> createState() => _SliderExampleState();
}
class _SliderExampleState extends State<SliderExample> {
double _currentSliderValue = 20;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Slider')),
body: Slider(
value: _currentSliderValue,
max: 100,
divisions: 5,
label: _currentSliderValue.round().toString(),
onChanged: (double value) {
setState(() {
_currentSliderValue = value;
});
},
),
);
}
}
这个示例是一个基本的Flutter应用程序,来自于Flutter官方。
- 当应用程序启动时,Slider的初始值是20。用户可以通过按住并拖动滑块来选择不同的数值。
- 滑块的最小值是0,最大值是100,滑块上有5个刻度线,表示5个离散的数值。当用户拖动滑块时,滑块的值会随着手指的拖动而实时更新。
- 在滑块的上方显示一个标签,显示当前所选数值的整数部分,例如,当用户将滑块移动到25时,标签显示"25"。
- 滑块的外观由activeColor(激活状态下的颜色)和inactiveColor(非激活状态下的颜色)属性定义。在示例中,激活状态下的颜色为蓝色,非激活状态下的颜色为灰色。
在Flutter中,您可以使用Canvas来进行绘图操作,Canvas是Flutter中的绘图上下文,允许您在屏幕上绘制各种形状、文本和图像。Canvas通常与CustomPaint小部件一起使用,以在Flutter的绘图流程中插入自定义绘图代码。
使用Canvas进行绘图的基本步骤包括:
创建一个CustomPaint小部件: 首先,您需要在Flutter应用程序的UI层次结构中插入一个CustomPaint小部件,以便将绘图内容放入其中。
自定义Painter: 您需要创建一个自定义的Painter类,它继承自CustomPainter,并实现paint和shouldRepaint方法。paint方法是您用来实际绘制内容的地方,shouldRepaint方法决定是否需要重新绘制。
在paint方法中绘制内容: 在paint方法中,您可以使用Canvas对象来进行各种绘图操作,如绘制图形、文本、路径等。Canvas提供了各种方法来绘制不同类型的图形。
例如,下面得代码展示了如何在Flutter中使用Canvas绘制一个简单的圆形:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyCustomPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.blue
..style = PaintingStyle.fill;
final centerX = size.width / 2;
final centerY = size.height / 2;
final radius = size.width / 3;
canvas.drawCircle(Offset(centerX, centerY), radius, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Canvas绘图示例'),
),
body: Center(
child: CustomPaint(
size: const Size(200, 200),
painter: MyCustomPainter(),
),
),
),
);
}
}
上述示例中,MyCustomPainter类继承自CustomPainter,并在其paint方法中绘制了一个蓝色的圆形。然后,CustomPaint小部件将MyCustomPainter作为其painter属性的值传递,并在UI中显示绘制的圆形。
通过Canvas和CustomPainter,您可以创建各种自定义绘图效果,包括图表、动画、自定义图形和复杂的UI元素。Canvas提供了丰富的绘图功能,可以满足各种绘图需求。
Flutter中的裁剪(Clip)是一种用于控制Widget可见区域的技术,它可以用来创建各种不同形状和效果的UI元素。裁剪允许您定义一个区域,只有在该区域内的部分内容才会被显示,超出该区域的内容会被裁剪掉。Flutter提供了多种不同类型的裁剪小部件,以满足各种需求。
以下是一些常见的Flutter裁剪小部件和其用途:
ClipRect: 这是最常见的裁剪小部件之一,它可以将其子部件裁剪为矩形形状。使用它可以创建各种矩形裁剪效果,例如将图像限制在矩形区域内。
ClipOval: 这个小部件可以将其子部件裁剪为椭圆形状,用于创建椭圆形的UI元素,如头像或按钮。
ClipRRect: ClipRRect用于创建带有圆角的矩形裁剪,可以用来创建圆角矩形框或卡片。
ClipPath: ClipPath允许您自定义裁剪区域的形状,通过提供一个自定义路径来实现各种复杂的裁剪效果。
这里是一个示例,展示如何使用ClipRect来裁剪一个图像以显示在矩形区域内:
ClipRect(
child: Image.network(
'https://example.com/image.jpg',
width: 200,
height: 200,
fit: BoxFit.cover,
),
)
上述示例中,ClipRect将Image小部件裁剪为矩形区域内的可见部分。您可以使用其他Clip类型来创建不同形状和效果的裁剪。
裁剪在创建各种自定义UI效果时非常有用,例如创建特定形状的按钮、卡片或背景。通过使用不同的Clip类型,您可以实现各种各样的外观和动画效果,从而增强Flutter应用程序的用户界面。
/// 作者:李俊才
/// 邮箱:291148484@163.com
/// 项目地址:http://thispage.tech:9680/jclee1995/flutter-jc-captcha
/// 协议:MIT
import 'dart:math';
import 'package:flutter/material.dart';
/// 验证码组件
///
/// 这个组件用于显示一个验证码图像,用户需要滑动滑块以解锁验证。当验证成功或失败时,
/// 分别触发 [onSuccess] 或 [onFail] 回调函数。你可以设置允许的误差范围 [deviation]
/// 以调整验证的精确性。
class CaptchaWidget extends StatefulWidget {
/// 用作验证图像的URL
final String imageUrl;
/// 当验证成功时触发的回调函数。
final Function() onSuccess;
/// 当验证失败时触发的回调函数。
final Function() onFail;
/// 允许的误差范围,用于调整验证的精确性。
static double deviation = 5;
/// 创建一个 [CaptchaWidget] 小部件,需要指定 [imageUrl]、[onSuccess] 和 [onFail] 回调函数。
const CaptchaWidget({
Key? key,
required this.imageUrl,
required this.onSuccess,
required this.onFail,
}) : super(key: key);
@override
State<CaptchaWidget> createState() => _CaptchaWidgetState();
}
class _CaptchaWidgetState extends State<CaptchaWidget> {
/// 滑块的当前位置。
double _sliderValue = 0.0;
late double _offsetRate;
/// 用于定位的偏移值。
late double _offsetValue;
/// 小部件的总宽度。
late double width;
/// 用于确保验证仅仅一次有效
bool _verified = false;
double _generateRandomNumber() {
// 创建一个Random对象
var random = Random();
// 生成一个介于0.1和0.9之间的随机小数
double randomValue = 0.1 + random.nextDouble() * 0.7;
return randomValue;
}
@override
void initState() {
_offsetRate = _generateRandomNumber();
super.initState();
}
@override
Widget build(BuildContext context) {
width = MediaQuery.of(context).size.width;
_offsetValue = _offsetRate * width;
return Column(
children: [
// 堆叠三层,背景图、裁剪的拼图、拼图的轮廓绘图
Stack(
alignment: Alignment.center,
children: [
// 背景图层
Image.network(
widget.imageUrl,
height: 200.0,
fit: BoxFit.cover,
),
// 背景标记层
CustomPaint(
size: Size(width, 200.0),
painter: CaptchaBorderPainter(_offsetValue),
),
// 拼图层
Positioned(
left: _sliderValue * width - _offsetValue,
child: ClipPath(
clipper: CaptchaClipper(_sliderValue, _offsetValue),
child: Image.network(
widget.imageUrl,
height: 200.0,
fit: BoxFit.cover,
),
),
),
// 拼图的轮廓层
Positioned(
left: _sliderValue * width - _offsetValue,
child: CustomPaint(
size: Size(width, 200.0),
painter: CaptchaBorderPainter(_offsetValue),
),
),
],
),
//
SliderTheme(
data: SliderThemeData(
thumbColor: Colors.white, // 滑块颜色为白色
activeTrackColor: Colors.green[900], // 激活轨道颜色为深绿色
inactiveTrackColor: Colors.green[900], // 非激活轨道颜色为深绿色
trackHeight: 10.0, // 轨道高度
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 10.0), // 滑块形状为圆形
),
child: Slider(
value: _sliderValue,
onChanged: (value) {
setState(() {
_sliderValue = value;
});
},
onChangeEnd: (value) {
if (_verified == false) {
if (_sliderValue.abs() * width >
_offsetValue - CaptchaWidget.deviation &&
_sliderValue.abs() * width <
_offsetValue + CaptchaWidget.deviation) {
widget.onSuccess();
_verified = true;
} else {
widget.onFail();
_verified = true;
}
}
},
),
),
],
);
}
}
/// 用于创建中滑动拼图的自定义剪切器。
class CaptchaClipper extends CustomClipper<Path> {
final double sliderValue;
final double offsetValue;
/// 创建一个 [CaptchaClipper],需要指定 [sliderValue] 和 [offsetValue]。
CaptchaClipper(this.sliderValue, this.offsetValue);
@override
Path getClip(Size size) {
final path = Path();
final rect = RRect.fromRectAndRadius(
Rect.fromPoints(
Offset(offsetValue + size.width * sliderValue, 60),
Offset(
offsetValue + size.width * sliderValue + 80,
size.height - 40,
),
),
const Radius.circular(10.0),
);
path.addRRect(rect);
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) {
return false;
}
}
class CaptchaBorderPainter extends CustomPainter {
final double offsetValue;
CaptchaBorderPainter(this.offsetValue);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.black
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
final rect = Rect.fromPoints(
Offset(offsetValue, 60),
Offset(
offsetValue + 80,
size.height - 40,
),
);
final path = Path()
..addRRect(RRect.fromRectAndRadius(rect, const Radius.circular(10.0)));
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}
这里的控制也就是通过Slider的位置控制上层图片的位置,实现同步移动效果。
用作背景的图片,这张图片要求有一定的长度(比宽高),它会平铺开并安装长度覆盖。而对于超出的高度则不会显示。因此主要需要确保相对于高度而言这样图片长度不能短了;这部分:
// 背景图层
Image.network(
widget.imageUrl,
height: 200.0,
fit: BoxFit.cover,
),
TODO: 这一版本都使用了固定的高度,日后可以给个调整的值。
用作强调背景图中对齐位置的轮廓绘图,表示用户操作上层图片的目标位置。这部分是有canvas绘图实现的。:
// 背景标记层
CustomPaint(
size: Size(width, 200.0),
painter: CaptchaBorderPainter(_offsetValue),
),
Flutter中,可以使用ClipPath将图片裁剪为任何想要的形状,用起来就像Canvas绘图一样。这部分将一张与最底层完全重叠、完全一样的图片裁剪为想要的形状(此版本已圆角矩形为例),只不过这个图片由于是堆叠再上方,因此需要设计得小一点。然后再对这个被裁剪得区域移动到最左端——从而适配滑块一开始是再最左端得:
// 拼图层
Positioned(
left: _sliderValue * width - _offsetValue,
child: ClipPath(
clipper: CaptchaClipper(_sliderValue, _offsetValue),
child: Image.network(
widget.imageUrl,
height: 200.0,
fit: BoxFit.cover,
),
),
),
// 拼图的轮廓层
Positioned(
left: _sliderValue * width - _offsetValue,
child: CustomPaint(
size: Size(width, 200.0),
painter: CaptchaBorderPainter(_offsetValue),
),
),
总水平长度是通过媒体查询来确定的,这对于移动设备来说,不会存在动态改变设备宽度的问题,因此也没有实时媒体查询的必要。总体长度将保存在以下字段中:
/// 小部件的总宽度。
late double width;
首先背景图是不需要便宜的,需要便宜的是上面的各个堆叠层。以下的所有偏移按照相对于左侧位置计算。
我考虑了一个内部的 _generateRandomNumber 方法,用于随机生成一个总位置 0.1~0.9 之间的偏移率,用于滑动验证成功的位置。代码为:
double _generateRandomNumber() {
// 创建一个Random对象
var random = Random();
// 生成一个介于0.1和0.9之间的随机小数
double randomValue = 0.1 + random.nextDouble() * 0.7;
return randomValue;
}
这个便宜率需要在初始化状态时固定并暂存下来,放在_offsetRate中,可以使用State类的initState实现:
@override
void initState() {
_offsetRate = _generateRandomNumber();
super.initState();
}
_offsetRate 的固定对于基于Clip的CaptchaClipper类的getClip方法中没有什么影响,应为在Flutter中Clip是不需要总是去重新绘制的,但是在基于Canvas的CaptchaBorderPainter就不一样了——毕竟CustomPainter类的paint方法会被不断调用,以至于如果不固定随机生成的_offsetRate ,则不断调用_generateRandomNumber方法导致描边位置错乱。实际的偏移量,无非是媒体查询出来的宽度去乘以这个便宜率:
width = MediaQuery.of(context).size.width;
_offsetValue = _offsetRate * width;
背景标记层的偏移是一个固定的偏移量,这个偏移量由初始化的_offsetValue确定就不需要改:
CustomPaint(
size: Size(width, 200.0),
painter: CaptchaBorderPainter(_offsetValue),
),
在 CaptchaBorderPainter 中:
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.black
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
final rect = Rect.fromPoints(
Offset(offsetValue, 60),
Offset(
offsetValue + 80,
size.height - 40,
),
);
final path = Path()
..addRRect(RRect.fromRectAndRadius(rect, const Radius.circular(10.0)));
canvas.drawPath(path, paint);
}
可见,offsetValue不变则水平位置再不变。
拼图层是对于一个和背景图大小完全一样的图片的一个水平随机位置的一小块裁剪的,裁剪的初始有一个随机的偏移量,和背景标记层偏移是一样的,就是 offsetValue,着只不过是一个初始的裁剪距离左侧的偏离距离,即CaptchaClipper中:
@override
Path getClip(Size size) {
final path = Path();
final rect = RRect.fromRectAndRadius(
Rect.fromPoints(
Offset(offsetValue + size.width * sliderValue, 60),
Offset(
offsetValue + size.width * sliderValue + 80,
size.height - 40,
),
),
const Radius.circular(10.0),
);
path.addRRect(rect);
return path;
}
前面有一个“offsetValue + …”。就是初始在相对于原图片左边的偏移量。正因为有了这个量,裁剪的不是图片左边的一部分,但是下面滑块却是初始时位于最左边的,我们需要在堆叠时将这个偏移量减去,就使得偏移的裁剪与底下的滑块初始时是“对齐”的:
// 拼图层
Positioned(
// 减去偏移量与滑块对齐
left: _sliderValue * width - _offsetValue,
child: ClipPath(
clipper: CaptchaClipper(_sliderValue, _offsetValue),
child: Image.network(
widget.imageUrl,
height: 200.0,
fit: BoxFit.cover,
),
),
),
这样也就是表明,一开始的位置是最左边的位置,只有用户滑动滑块才会有可能移动到验证成功的位置!
拼图的轮廓层偏移 和 拼图层偏移的值始终保持相等的,但是需要注意的是,由于它们使用的技术分别是CustomClipper和Canvas,Clip的getClip 和 CustomPainter 的 paint执行时机不同,为了使得在paint中采用和相同的offsetValue,我们将第一次随机生成的偏移量做过缓存。
// 拼图的轮廓层
Positioned(
left: _sliderValue * width - _offsetValue,
child: CustomPaint(
size: Size(width, 200.0),
painter: CaptchaBorderPainter(_offsetValue),
),
),
使用Slider实现的滑动控制器一些UI效果实现还不好看,用起来也不方便触摸,如果改成矩形的可能会更好。下一步计划使用绘图来替代Slider绘制矩形风格的滑块和滑槽,滑块可以使用拟物风格的图标。
上面绘制的轮廓是简单的圆角矩形,不过如果改版为拼图的常见形状,比如:
会更加好看一些。
可以将再下一个版本中可以考虑重新使用Canvas绘制。
如果验证成功后,可以在整个图片叠加区域上面添加一个半透明白色堆叠物实现一个成功覆盖层,并且在中间加一个圆形背景的勾(√)来增强效果。
有时候可能用户一次验证没有成功,但也不意味着是机器人。目前可以重新构建组件,并传入新的图片来。不过可以考虑一个用于直接刷洗验证码的接口。
为了让用户体验更加丝滑,可以考虑手势的时间计算,一旦验证成功,则告诉用于“本次认证使用了xx秒,超过了99%的用户”。
- 点赞
- 收藏
- 关注作者
评论(0)