资讯动态

Flutter网络请求封装:Dio拦截器与全局Loading状态管理实践

发布时间:2026/10/17 6:56:11 来源:尧图企业网站定制
写了不少Flutter业务代码之后我越来越觉得“网络请求”这件事根本不该在每个页面里重复造轮子。很多人刚开始接触Flutter的时候都会这么写在某个页面里直接new一个Dio请求前手动调用showDialog请求成功后自己dismiss中间要是遇到超时、断网、接口报错还得在每个页面里分别处理。短期内看起来没什么问题页面一多维护成本立刻翻倍。尤其是“加载框”这个东西它跟请求的关联比你想象中更紧密显示早了会闪一下关闭晚了用户以为App卡死关闭早了解析还没完成界面就白屏种种细节逼着你把网络请求和Loading状态统一管理起来。这篇文章要聊的就是我在项目里实际落地的一套方案基于Dio的二次封装重点解决“带加载框的网络请求”这个场景。核心思路是把请求发送、加载框显示、错误统一处理这三件事塞进同一个类里业务方调用时只需要关心数据回来之后做什么。整体内容不涉及复杂框架也不依赖状态管理库用Flutter原生能力加Dio本身就能实现。1. 为什么封装网络请求是“基础设施”级的事1.1 不复用直接写在Page里会遇到的问题先看一个很典型的反面教材。业务页面的代码往往长这样Futurevoid fetchUserInfo() async { showDialog( context: context, barrierDismissible: false, builder: (_) const Center(child: CircularProgressIndicator()), ); try { final response await Dio().get(https://api.example.com/user/info); Navigator.of(context).pop(); // 解析response.datasetState } on DioException catch (e) { Navigator.of(context).pop(); // 展示错误信息 } catch (e) { Navigator.of(context).pop(); // 处理其他异常 } }这段代码在单个页面里看起来问题不大但只要你面临以下几个场景就知道问题有多痛页面里同时有多个请求每个请求都要弹Loading的话前一个请求关闭Loading时后一个请求的Loading也会被误关。接口失败时我们确实把Loading关掉了但错误提示的代码散落在每个页面里有的页面甚至忘了处理失败状态Loading一直悬在屏幕上。请求的BaseUrl、公共Header、超时时间等配置在每个请求里都要重复写一遍后续想统一改域名或加版本号根本无从下手。想在每个请求的Header里自动带上登录Token想在请求日志里看到完整的URL和耗时想统一处理Token过期这些横切逻辑在业务页面里没法干净落地。这些问题的本质只有一个网络请求不是一个“点”而是一条贯穿所有业务页面的“线”。用实体类来管理请求时这条线的每一个节点配置、拦截器、加载框、错误处理都是独立且重复的最终代码会变得又脏又难维护。1.2 加载框该由谁控制两种方案的比较关于加载框的控制权通常有两条路可走一条是“业务页面自己控制”。就像上面的反面教材每个页面自己弹窗、自己关闭。好处是灵活可以针对单个页面做特殊交互动画坏处也明显代码重复率高真正遇到并发请求时很难把状态管理干净。另一条是“请求管理类统一控制”。把加载框的显示和关闭交给Dio的拦截器来做请求发出前显示请求结束成功或失败后关闭。业务方通过一个开关告诉管理类“这次请求我要不要显示Loading”其他事情一概不用管。这种方案的优势在于加载框和整个页面的生命周期是解耦的我们可以在全局导航的顶层插入一个Overlay遮罩它不依赖某个页面的BuildContext所以不存在“页面已经销毁但还拿着Context弹框”的问题。我在项目里选的是第二种方案而且是基于Overlay而不是Dialog实现的。这里可能有同学会问用showDialog弹一个加载框不是更简单吗为什么非要Overlay原因是一个很隐蔽的坑。showDialog内部走的是Navigator的Route栈它会拦截返回事件而且当你连续弹Loading时Dialog会叠加在原有页面之上。问题是Dio拦截器里拿不到原页面可靠的Context如果拿一个有可能失效的Context去弹Dialog在异步请求回来时很可能触发“Looking up a deactivated widgets ancestor is unsafe”这类异常。Overlay则不同它挂在Navigator的Overlay上只插入一个指定位移区域的覆层不占路由栈不需要Context也能插入和移除。放在拦截器里做全局Loading时这个方案稳定得多。2. 封装前的预备工作依赖与目录设计2.1 引入Dio版本选择需要注意的地方Dio作为Flutter生态里公认最活跃的HTTP库在封装网络层时几乎是首选。它的拦截器机制、全局配置、取消请求能力都相当成熟可以在不侵入业务代码的前提下完成统一逻辑处理。在pubspec.yaml中引入依赖比较简单dependencies: dio: ^5.4.0Dio的5.x版本相比4.x有几处明显的API调整比如错误类型从DioError改为了DioException请求配置里部分字段的命名也发生了改动如果你是从4.x项目升级上来编译时会发现不少代码需要跟着改。这里有一条实践建议引入Dio后第一步先用官方示例写一个最小的GET请求确认当前版本的API风格再去封装。避免在封装过程中因为版本API不熟悉把拦截器的类型写错白白浪费时间。2.2 项目目录结构和类的职责网络层不应该是一个孤零零的文件而是应该按照职责拆分成几个轻量级的文件。我习惯这样组织lib/ ├── common/ │ └── navigator_key.dart # 全局navigatorKey和overlay可用性管理 ├── network/ │ ├── http_manager.dart # 请求管理类创建Dio实例并对外暴露请求方法 │ ├── loading.dart # Overlay加载框的显示/关闭管理器 │ ├── loading_view.dart # 加载框UI组件 │ ├── interceptors.dart # 请求拦截器、响应拦截器 │ └── api_status.dart # 业务错误状态码统一定义 └── config/ └── app_config.dart # BaseUrl、Token、超时时间配置拆分文件的逻辑很简单加载框UI和加载框控制逻辑分开拦截器只负责在合适时机调用Loading管理器请求管理类只负责创建Dio实例、注册拦截器、提供对外方法。这样后期任何一个模块出问题都能快速定位。举个例子如果某一天设计稿要求把加载框从圆环形动画改成骨骼屏你只需要改loading_view.dart这一个文件和请求管理类完全无关。3. 核心实现带加载框的请求封装3.1 请求管理类的初始化与基础配置请求管理类走单例模式它的职责是产出配置好的Dio实例对外暴露一个无感知的请求接口。为了让拦截器能够拿到全局的加载框控制能力我们还需要一个全局的navigatorKey。先看navigatorKey的定义// lib/common/navigator_key.dart import package:flutter/material.dart; final GlobalKeyNavigatorState navigatorKey GlobalKeyNavigatorState();在应用的MaterialApp里注册这个KeyMaterialApp( navigatorKey: navigatorKey, home: const HomePage(), )加这个Key的原因前面提过拦截器不持有具体页面的Context所以弹全局Loading必须挂载在Navigator顶层的Overlay上。有了navigatorKey我们可以在任何地方拿到navigatorKey.currentState.overlay来插入覆层。然后看请求管理类的初始化// lib/network/http_manager.dart import package:dio/dio.dart; import interceptors.dart; import ../config/app_config.dart; class HttpManager { HttpManager._internal() { dio Dio( BaseOptions( baseUrl: AppConfig.baseUrl, connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 15), ), ); dio.interceptors.add(CommonInterceptor()); dio.interceptors.add( LogInterceptor( requestBody: true, responseBody: true, ), ); } static final HttpManager _instance HttpManager._internal(); static HttpManager get instance _instance; late final Dio dio; }这里有一个细节BaseOptions里面配置的connectTimeout和receiveTimeout在Dio 5.x版本中建议用Duration类型旧版int类型虽然还可以用但语义不如Duration直观。超时时间不是越长越好15秒对于大多数移动端接口来说已经足够宽裕如果某些接口本身就很慢可以在后续拦截器逻辑里按接口路径单独调整。LogInterceptor在生产环境中一般不直接启用可以通过一个bool开关控制或者只在debug模式下注册if (AppConfig.isDebug) { dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true)); }3.2 用拦截器统一控制加载框的显示与关闭拦截器是整个封装方案的心脏。Dio的请求流程是请求发出前依次执行onRequest收到响应后执行onResponse出错后执行onError。我们把Loading显示挂在onRequest里把Loading关闭挂在onResponse和onError里即可。先把加载框管理器写好// lib/network/loading.dart import package:flutter/material.dart; import ../common/navigator_key.dart; import loading_view.dart; class Loading { Loading._(); static OverlayEntry? _overlayEntry; static int _requestCount 0; static void show() { _requestCount; if (_overlayEntry ! null) { return; } final overlay navigatorKey.currentState?.overlay; if (overlay null) { return; } _overlayEntry OverlayEntry( builder: (_) const LoadingView(), ); overlay.insert(_overlayEntry!); } static void hide() { if (_requestCount 0) { _requestCount--; } if (_requestCount 0) { return; } _overlayEntry?.remove(); _overlayEntry null; } }这个Loading类的设计有一个关键点引入了_requestCount计数器。它解决的是并发请求时只有最后一个请求返回导致Loading提前被关闭的问题。举一个具体场景页面上同时发起两个请求第一个请求发出后Loading显示第二个请求发出后_requestCount变成2。如果第一个请求先返回hide()被调用此时_requestCount从2减到1不等于0所以Loading不会关闭。只有第二个请求也返回_requestCount减到0Loading才真正关闭。这个机制虽然简单但在真实业务中是最省心的。再看拦截器实现// lib/network/interceptors.dart import package:dio/dio.dart; import loading.dart; import ../config/app_config.dart; class CommonInterceptor extends Interceptor { override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { // 在这里统一给Header加Token // options.headers[Authorization] Bearer ${AppConfig.token}; if (options.extra needLoading) { Loading.show(); } handler.next(options); } override void onResponse(Response response, ResponseInterceptorHandler handler) { if (response.requestOptions.extra needLoading) { Loading.hide(); } handler.next(response); } override void onError(DioException err, ErrorInterceptorHandler handler) { if (err.requestOptions.extra needLoading) { Loading.hide(); } handler.next(err); } }这里我是按照逻辑写法的伪代码extra需要进一步说明。Dio的RequestOptions自带一个extra字段类型是MapString, dynamic专门用来携带业务自定义参数。我们可以利用它来决定“这次请求要不要显示Loading”。完整的调用姿势应该是在HttpManager的对外方法里设置extra字段然后拦截器读取。之所以不用一个额外的bool参数来传递是因为RequestOptions会被传递到拦截器链中extra里的数据能跟随整个请求生命周期非常顺滑。3.3 响应统一处理和业务状态透传接口的返回结构在大多数团队里是统一的比如这样{ code: 0, message: success, data: { ... } }面对这种结构我们可以在onResponse里做统一的状态检查。假设业务约定code 0表示成功其他code代表业务失败哪怕HTTP状态码是200也不能直接把response.data交给业务方而是要根据业务code进一步判断。这里我通常会在拦截器里把错误转换成DioException抛给上层override void onResponse(Response response, ResponseInterceptorHandler handler) { if (response.requestOptions.extra[showLoading] true) { Loading.hide(); } final data response.data; if (data is MapString, dynamic) { final code data[code]; if (code ! 0) { handler.reject( DioException( requestOptions: response.requestOptions, type: DioExceptionType.badResponse, message: data[message] ?? 未知错误, ), ); return; } } handler.next(response); }这样做的好处是业务方拿到的response.data一定是成功数据不用在每个页面里写重复的code判断。错误提示信息可以在上层统一通过SnackBar或者Toast弹出而不是散落在各个页面。4. 对外API设计怎么调用才舒服4.1 一个方法搞定加载、请求、回调封装的目标是让调用方舒服而不是让封装本身显得很复杂。我在HttpManager中暴露的方法是泛型方法返回值使用Future业务方可以自由选择async/await或是.then方式。实际方法签名大致是这样的FutureResponseT getT( String path, { MapString, dynamic? queryParameters, bool showLoading false, CancelToken? cancelToken, }); FutureResponseT postT( String path, { MapString, dynamic? data, bool showLoading false, CancelToken? cancelToken, });注意这里的showLoading参数最终会写入RequestOptions.extraFutureResponseT getT( String path, { MapString, dynamic? queryParameters, bool showLoading false, CancelToken? cancelToken, }) async { return dio.getT( path, queryParameters: queryParameters, cancelToken: cancelToken, options: Options( extra: {showLoading: showLoading}, ), ); }这样写的妙处是调用方一眼就能看懂“这次请求要不要弹加载框”这个关键信息而拦截器也能从extra里读到同一个开关。二者通过Dio自身的机制完成了信息传递不需要额外维护一份全局状态。4.2 关闭加载框的三种触发条件加载框的关闭其实牵涉到三个时机很多初学者只关注成功关闭忽略了另外两种请求成功响应返回此时触发onResponse关闭Loading。请求失败包括网络错误、超时、HTTP非2xx状态此时触发onError关闭Loading。业务方主动取消请求。主动取消也会走onError只不过错误类型是DioExceptionType.cancel。有一种情况特别容易踩坑请求已经发出但Loading还没有显示出来此时业务方主动调用了取消方法。按道理Loading的show和网络请求的send在时序上几乎是同时的取消也必然在请求完成之后所以Loading会正常显示、正常关闭。但若恰好取消动作发生在show之前loading._requestCount根本还没有增加后面的hide也不会误关“别的请求的Loading”。这得益于计数器机制并不会因为一次取消把其他请求的Loading关掉。在onError中我习惯对所有错误做一次统一处理但这里有一个细节要注意override void onError(DioException err, ErrorInterceptorHandler handler) { if (err.requestOptions.extra[showLoading] true) { Loading.hide(); } if (err.type DioExceptionType.cancel) { handler.next(err); return; } // 非取消场景才弹出错误提示 final message _messageFromError(err); // 全局Toast / SnackBar展示message handler.next(err); }取消请求是一种用户主动行为不应该像真正的错误那样弹提示。这个判断虽然只有几行但很影响用户体验不做的话用户点击“重试”然后手动取消上次请求时会看见一条多余的错误提示体验很差。4.3 返回数据怎么交给业务方关于返回值我见过几种不同的风格直接返回Response对象业务方自己取data或者返回解析好的Map还有更进一步用泛型直接解析成Model。这里要综合考虑代码量与类型安全。直接返回Response是最灵活的但对业务方不够友好因为每次都要写response.data。我倾向于在HttpManager中再向下走一步提供一个方法业务方传入解析回调由网络层完成解析FutureT requestT( String path, { required FutureT Function(dynamic data) parser, bool showLoading false, }) async { final response await getdynamic( path, showLoading: showLoading, ); return parser(response.data); }这样业务方的代码可以收敛得很干净final userInfo await HttpManager.instance.request( /user/info, showLoading: true, parser: (data) UserInfo.fromJson(data), );如果项目里已经接入了代码生成工具比如freezed、json_serializable这个parser回调的写法非常自然几乎不会带来额外学习成本。如果项目本身很轻直接用requestMapString, dynamic也完全可行。5. 加载框的状态管理细节5.1 并发请求下Loading关闭过早的问题并发请求是网络请求封装里最容易翻车的地方。假设页面上同时发出两个请求都开启了Loading那么问题来了第一个请求发出Loading显示。第二个请求发出理论上Loading还是那个Loading但如果我们每次show都强制插入一个新的OverlayEntry页面上就会出现两个Loading遮罩。第一个请求返回如果我们无脑移除Loading第二个请求还在飞Loading就消失了但用户的等待预期并没有结束。解决办法就是前面提到的计数器和“已存在则不重复插入”的双重保护。计数器负责记录正在等待的请求数只要还有请求在处理中就不移除Overlay_overlayEntry判空则避免出现多个Overlay叠加的情况。这个方案还有一个好处不同页面之间的请求也会共享同一个计数。比如从页面A发起请求后立刻跳转到页面B页面B也发起了一个请求。如果Loading生命周期只跟着某个页面的Context走很可能会出现页面A的Loading还挂在B页面的问题。而全局Overlay计数方案天然规避了这种跨页面状态的纠缠。5.2 手动设置场景并非所有请求都要弹框不是所有请求都需要Loading。比如用户下拉刷新时界面上已经存在RefreshIndicator动画再弹一个全屏Loading遮罩就显得很突兀再比如首页冷启动时的数据预热请求我们需要的是页面本身的骨架屏而不是一个悬浮遮罩。封装方案在这一点上保持了较高的灵活性showLoading只是一个bool开关默认false业务方按需开启。我实际推荐的做法是首次进入页面且页面内容完全依赖接口时开启全屏Loading。下拉刷新、上拉加载更多时不开启全屏Loading由页面的刷新组件自己控制状态。某些表单提交场景开启全屏Loading同时把Loading遮罩设计成不可取消防止用户误触导致重复提交。关于重复提交这里有一个额外的细节barrierDismissible的逻辑在Overlay方案里对应的是遮罩层是否拦截点击。我们要让LoadingView变成一个不透明的、捕获所有触摸事件的层这样用户点击不会穿透到下层页面。实现方式是不给LoadingView注册任何点击处理但它本身是全屏的ColoredBox自然就能挡住下层控件。如果想更安全也可以在里面放一个GestureDetector覆盖全屏但什么也不做。5.3 具体场景调用示例拿一个实际的用户登录场景为例。用户点击登录按钮此时我们希望显示全屏Loading防止用户反复点按钮同时避免界面交互混乱。代码可以这样写Futurevoid login() async { try { final response await HttpManager.instance.postLoginResponse( /auth/login, data: { username: _usernameController.text.trim(), password: _passwordController.text.trim(), }, showLoading: true, ); final token response.data?.token; // 处理登录成功逻辑 } on DioException catch (e) { // 网络层已经统一弹出错误提示这里只需要处理业务兜底逻辑 } }从业务方视角看它只做了一件事告诉网络层我要调这个接口并且要弹Loading。至于Loading什么时候显示、什么时候关闭、错误怎么提示、Token怎么加全部由网络层搞定。这就是封装的意义。要注意的是登录场景中barrierDismissible不是靠参数控制的而是靠OverlayEntry的拦截能力控制的。上面提到过全屏Overlay天然具备拦截下层交互的作用。6. 一些实践中的坑和优化6.1 内存泄漏与对话框悬挂OverlayEntry的移除如果漏掉后果非常明显Loading遮罩永远挂在屏幕上用户点击任何地方都没有反应。排查这类问题时我通常会先检查onError中是否在所有分支都调用了Loading.hide()。由于拦截器中一个if条件就完成了hide倒是不会漏但业务方如果有自己的try/catch也可能出现异常后提前return导致请求实际上还未完成Loading提前关闭而OverlayEntry还在。这里的根因是请求对象的生命周期没有管理好。另一个需要关注的坑是不要在LoadingView里使用BuildContext去弹新的页面。LoadingView挂在全局Overlay上它的Context不属于任何业务页面一旦用它去Navigator.push很可能出现不可控的路由行为。所有跳转应该走业务页面自己的Context或者使用全局navigatorKey。6.2 关于超时和重试的一些建议Dio本身没有自动重试机制需要自己实现或引入三方组件。我的建议是不到万不得已别做全局自动重试因为很多接口是非幂等的重试可能带来脏数据。真要重试时可以通过拦截器判断DioExceptionType.connectionTimeout并且只对GET请求重试重试次数1-2次。在BaseOptions中设置超时时的细节如下options: BaseOptions( connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), sendTimeout: const Duration(seconds: 10), )根据我的经验connectTimeout设置太短反而会在弱网环境下频繁触发超时。国内网络环境比较复杂运营商出口偶尔会卡顿过短的超时会给用户带来“怎么老失败”的错觉。10到15秒是一个相对稳妥的区间。超时错误与取消请求的区分也值得单独提一下。DioException的type字段可以帮我们判断具体错误类别常见的场景和对应建议整理成表格如下错误类型触发场景建议处理方式badResponse服务端返回非2xx状态码或业务code不为0统一弹错误消息可按状态码细分connectionTimeout建连超时提示“网络连接超时”receiveTimeout响应超时提示“响应超时”可以考虑自动重试一次GET请求cancel主动取消请求静默处理不弹错误提示unknown未知错误/解析异常打印日志统一提示“请求失败”6.3 日志输出与排查技巧封装过网络层的人都明白调试接口时最需要的是一个看得懂、切得开维度的日志。默认的LogInterceptor会把完整请求和响应打印出来但缺点是量大、格式不统一。我习惯在LogInterceptor前面再包一层只打印关键信息请求方法、路径、耗时、状态码。这样在集成开发环境的控制台里扫一遍就能快速定位哪一次请求慢、哪个接口挂了。可以在拦截器里简单地记录发起时间和结束时间final DateTime startTime DateTime.now(); override void onRequest(...) { options.extra[startTime] startTime; handler.next(options); } override void onResponse(...) { final startTime response.requestOptions.extra[startTime] as DateTime; final cost DateTime.now().difference(startTime).inMilliseconds; // 打印 cost }有了这个耗时信息我在调优接口时心里就非常有底到底是接口本身慢还是手机性能造成UI卡顿一眼就能分辨。页面上的Loading其实并不在意这个耗时但为了方便对比把耗时和路由名拼在一起输出是个好习惯。还有一个小技巧不要把loading.show()放在onRequest里后就不管UI线程。Dio的请求本身是异步任务onRequest是在主线程执行的所以插入Overlay是安全的。如果后续有人改成在子线程里设置Overlay很容易触发Flutter的UI线程断言。6.4 LoadingView和整体UI的一致性加载框的UI外观也是封装的一部分。我常用的LoadingView是这样实现的// lib/network/loading_view.dart import package:flutter/material.dart; class LoadingView extends StatelessWidget { const LoadingView({super.key}); override Widget build(BuildContext context) { return Container( color: Colors.black.withOpacity(0.2), alignment: Alignment.center, child: Container( padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 24), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), ), child: const Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 12), Text(加载中...), ], ), ), ); } }这里的半透明黑色背景是全屏的可以遮住底下的按钮和列表防止用户误操作。加载框内部的圆角白底卡片在深色背景下视觉效果也还可以但若是APP主题支持深色模式别忘了给这个View配一套合适的颜色否则深色模式下白卡片会显得很突兀。在LoadingView里不建议放过于复杂的动画因为OverlayEntry插入和移除时如果动画帧数过高低端机会感觉到明显掉帧。业务中常见的圆环转圈就完全够用了。真想要更精致的响应式加载效果可以把LoadingView改为接收自定义Widget的通用容器这样不同业务页面可以传入不同的加载动画而管理框架保持一致。7. 最后再分享一个我实际在用的进阶技巧封装带加载框的网络请求核心不只是Dio配置和Overlay管理更关键的是站在调用方的位置倒推API设计。我们在项目里是这么处理的HttpManager只暴露request一个方法Loading开关、解析逻辑、错误提示全部由网络层消化页面代码平均能减少20行以上而且越复杂的页面越明显。我在实际实践中还习惯把“加载框是否允许用户点击返回键取消”也做一个开关。实现方式不算复杂全屏Overlay时默认拦截所有点击但用户按系统返回键时会触发路由返回操作。为了避免返回时带走Loading我们可以在NavigatorPopHandler或者路由层做一次拦截。不过大多数业务场景下Loading出现时通常不允许用户返回所以我直接在MaterialApp外层包了一层处理把所有路由返回动作都先交给网络层确认。如果你正准备给自己的Flutter项目做网络层改造又担心一次性改动太多导致风险我建议分三步走先把Dio实例和拦截器搭起来只做统一错误处理不接Loading稳定运行两个版本后再加入全局Loading管理最后再考虑并发计数器、取消请求、日志优化这些增强功能。这样每一阶段都能验证不会一脚踩进坑里。我自己踩过的最深的坑还是那些形形色色的“Context失效”问题。自从改用全局Overlay管理Loading、用navigatorKey处理路由跳转之后这类问题再也没有出现过。这套方案不敢说是最优解但它的容错率确实比在每个页面手动弹Dialog高得多业务团队接手也快。如果你正在Flutter项目里为“网络请求加Loading”这件事烦恼不妨直接照着这个思路落地一套。关键不是照搬代码而是抓住三个要点请求配置集中管理、拦截器负责横切逻辑、Loading生命周期与请求次数严格绑定。把这三点想透后续不管遇到什么网络层需求你都不会再无所适从。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑