干净优雅的做iOS应用内全局交互屏蔽

本文字数:4930字

预计阅读时间:28分钟

01

交互屏蔽的需求

很多应用开发者都会遇到这样一个需求,当程序需要处理某个敏感的核心任务,或者执行某些动画时,需要杜绝一切外部干扰,优先保证任务的完成,之后再去处理其它任务。否则如果在处理过程中受到外部事件的干扰,可能会引入严重的问题,而规避这些问题需要额外编写过多的逻辑。

例如,当程序在忙着清理应用内缓存的过程中去处理其它任务,这时候由于其它任务可能会产生新的缓存,这就会和现有的任务冲突。所以在清理缓存的过程中,app 一般会暂时中断用户和非用户的请求,优先保证缓存清理的完成。

所以,为了简化产品设计逻辑,开发者一般会选择在处理任务时暂时屏蔽其它任务,优先保障现有任务的完成。

举例来说,当用户点击清理缓存时,应用程序可能会弹出一个带有进度条的清理界面,在该界面下,清理工作紧张的进行着,并且告知用户正在清理任务,请稍候。

另一个需求是和动画有关,有时候我们在应用内可能会执行一些小动画,例如按钮的淡入淡出,整个页面的切换等。这些动画可能不会因为用户做快速的操作导致程序崩溃,但是因为每个动画都要时间完成,如果用户快速乱点的话,有可能会出现意想不到的情况。

例如,假设用户点击某个开关切换按钮,开关状态为开时,屏幕侧边以动画形式弹出侧边栏,当开关状态为关时,屏幕侧边栏以动画形式消失。那么如果用户快速反复点击按钮,而开发者没有处理好开关切换间隙的逻辑的话,那么就会出现侧边栏弹出动画还没执行完,就立刻消失的情况。

值得注意的是,这一类事件包括但不限于用户触摸事件,还有屏幕重力感应的变化等非用户输入事件,这就意味着这一类问题如果要优雅解决的话,不能单靠添加一个"触摸屏蔽层"。

02

常见的解决办法

对于以上问题,开发者选择的解决办法主要是两种:

第一个办法,设计一个布尔变量记录当前是否正在执行任务(或处理动画),处理这个过程中的交互逻辑。

这个办法本身没什么问题,但是开发者不得不针对每个任务去编写对应的逻辑,这样写起来就特别容易散乱。

第二个办法,设置 UIView 或者控件的 userInteractionEnabled 为 false ,并在合适的时机重新变为 true 。

这样做有个好处是,将整个 UIView 设置不可交互后,用户点击其它按钮也不会造成影响,但同时,如果对每个 UIView 去处理类似的逻辑,一不小心很容易出现 bug,最后导致整个 UIView 都卡住无法点击。

另外,应用可能存在多个 UIView,你锁住了一个 UIView,其它 UIView 的点击情况是否要考虑呢?

有些开发者用了更好的办法,他们直接用 UIViewController 的 UIView 来做 userInteractionEnabled 的处理,这样的解决方案更进步了,但是同样存在多个 UIViewController 这个问题,虽然有效,但还欠缺优雅。

另一方面,如上述的所有方法,仅仅能拦截"触摸事件",而不能拦截非触摸事件,例如加速器,摄像头事件等,如果代码针对这些事件会做出响应,而开发者不希望在任务期间去响应他们,将被迫去添加逻辑来屏蔽才行。

03

重新理解 UIApplication

我们对 UIApplication 不陌生了,我们经常需要通过调用UIApplication.sharedApplication。

在 iOS 的应用层 API 中,UIKit 最顶层的交互机制是通过 UIApplication 的 方法下发的 sendEvent :

go 复制代码
- (void)sendEvent: (UIEvent*)event

UIEvent 不止包括触摸事件,它还支持例如加速度事件等别的事件类型。

go 复制代码
// UIEvent 的事件类型
typedef NS_ENUM(NSInteger, UIEventType) {
    UIEventTypeTouches,
    UIEventTypeMotion,
    UIEventTypeRemoteControl,
    UIEventTypePresses,
    UIEventTypeScroll,
    UIEventTypeHover,
    UIEventTypeTransform,
};

以上是 UIApplication 中的事件类型,其中最值得关注的是 UIEventTypeTouches 和 UIEventTypeMotion,因为这是开发者最常用于响应输入的事件。

04

如何拦截 sendEvent,先搞懂 UIApplicationMain

要拦截 sendEvent,就要了解 UIApplicationMain,几乎每个 iOS 开发者都会碰到它,因为它就在 main 函数里:

go 复制代码
int main(int argc, char * argv[]) {
    NSString* appDelegateClassName;
    @autoreleasepool {
        appDelegateClassName = NSStringFromClass([AppDelegate class]);
    }
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

UIApplicationMain 相当于 iOS 应用自己的 main 函数,它的参数有 4 个,分别为 argc, argv,principalClassName 和 delegateClassName 。

其中前两个参数就是 C 语言 main 函数的参数,appDelegateClassName 传入的是 AppDelegate 的类名,第三个参数则传入用户自定义的 UIApplication 子类。

我们定义一个继承自 UIApplication 的 MyApplication 类后,main 函数就可以传入 MyApplication 类了。

go 复制代码
int main(int argc, char * argv[]) {
    NSString* appDelegateClassName;
    NSString* applicationClassName;
    @autoreleasepool {
        appDelegateClassName = NSStringFromClass([AppDelegate class]);
        applicationClassName = NSStringFromClass([MyApplication class]);
    }
    return UIApplicationMain(argc, argv, applicationClassName, appDelegateClassName);
}

还有一个方法可以不通过修改 main 函数来指定 MyApplication,在 XCode 的 Info.plist 中,新建字符串键 "Principal class",其值填入子类名,即本例的 MyApplication,那么当 main 函数传入的参数是 nil 时,Info.plist 所注册的 "Principal class" 将会作为指定类。如果全部没有指定,则默认为 UIApplication 。

05

sendEvent 拦截实现

在 MyApplication 类的实现部分,我们可以开始继承 sendEvent :

go 复制代码
- (void)sendEvent: (UIEvent*)event {
    [super sendEvent: event];
}

当应用产生了 UIEvent 事件时,系统会调用 sendEvent,此时因为我们注册了 MyApplication,所以调用的是我们定义的 sendEvent 方法。在上个例子里,sendEvent 直接调用了父类的 sendEvent,相当于对所有事件都采取了默认处理。

接下来,如果我们打算屏蔽所有的加速器事件,那么可以这么写:

go 复制代码
- (void)sendEvent: (UIEvent*)event {
    if (event.type == UIEventTypeMotion) {
        NSLog(@"UIEventTypeMotion");
        return;
    }
    [super sendEvent: event];
}

这样,如果应用内有处理摇一摇的功能,以上方法可以保证摇一摇事件不会下发。

06

交互屏蔽的接口设计

显然,我们需要更弹性的处理 sendEvent,直接屏蔽的办法是"一刀切",更恰当的做法是需要屏蔽的时候才让它屏蔽。因此,我们可以为 MyApplication 设计一个布尔属性 eventdisabled:

go 复制代码
@interface MyApplication()
@property (nonatomic) BOOL eventDisabled;
@end

该属性默认值是 false,即允许所有 event ,这样,sendEvent 方法的实现更改如下:

go 复制代码
- (void)sendEvent: (UIEvent*)event {
    if (self.eventDisabled && (event.type == UIEventTypeTouches || event.type == UIEventTypeMotion)) {
        return;
    }
    
    [super sendEvent: event];
}

当 eventdisabled 为真时,app 在全局范围内禁用了用户的触摸和加速器输入事件,只有当 eventdisabled 为假时,一切照常进行。

虽然现在可以通过变量让 app 实现交互的禁用和启用了,但是我们可以设计的更弹性一点,做一个延时机制,保证延时过后交互可以恢复正常,不然开发者四处写布尔值设置的代码,一旦稍有不慎,整个界面卡住就糟了,为了程序的健壮性,可以设计如下方法:

go 复制代码
- (void)disableUserInteraction: (NSTimeInterval)duration {
    self.userInteractionEnabled = NO;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, duration*NSEC_PER_SEC), dispatch_get_main_queue(), ^{
        self.userInteractionEnabled = YES;
    });
}

然后,我们把该方法放到 UIApplication 的扩展中,在 MyApplication.h 加入:

go 复制代码
@interface UIApplication()
- (void)disableUserInteraction: (NSTimeInterval)duration;
@end

这样,当我们需要临时禁用用户输入时,可以这么调用:

go 复制代码
[UIApplication.sharedApplication disableUserInteraction: 0.3];

此时应用会在 0.3 秒内处于禁用状态,并在稍后自动恢复。

07

如何进一步优化

以上介绍的屏蔽方案已经可以解决大部分日常需求,但是还有优化空间。

例如,当有多次调用 disableUserInteraction 时,例如:

go 复制代码
[UIApplication.sharedApplication disableUserInteraction: 0.3];
[UIApplication.sharedApplication disableUserInteraction: 1.3];

我们会发现,0.3 秒后应用就恢复了交互,而 1.3 秒的屏蔽"失效"了。

为了解决这个问题,可以引入一个计数器,保证让交互屏蔽满足最长时间的那个请求。这部分大家可以作为习题自行解决。

相关推荐
tink5 分钟前
告别 Xcode IDE:用 VS Code + SweetPad + XcodeGen 开发 iOS 应用的完整指南
ios·swiftui·xcode
传奇开心果编程1 小时前
【Compose Multiplatform 跨端开发学与练】第8课 资源管理与主题
android·windows·学习·ios·kotlin·web·composer
传奇开心果编程3 小时前
【Compose Multiplatform 跨端开发学与练】第9课 测试与调试
android·学习·macos·ios·kotlin·web·composer
传奇开心果编程3 小时前
【Compose Multiplatform 跨端开发学与练】第4课 导航与路由
android·windows·学习·ui·ios·kotlin·composer
黑科技iOS上架3 小时前
iOS隐私合规扫描工具
ios·审核
传奇开心果编程3 小时前
【Compose Multiplatform 跨端开发学与练】第6课 状态管理与架构
android·学习·ui·ios·架构·kotlin·composer
传奇开心果编程3 小时前
【Compose Multiplatform 跨端开发学与练】第2课 Compose 基础语法
android·windows·学习·ui·ios·kotlin·composer
可乐鸡翅yeah_3 小时前
hls.js 缓冲区参数 maxBufferLength、maxBufferSize 通俗讲解与业务调参
开发语言·javascript·ios·音视频·safari·m3u8
茶底世界之下1 天前
Metal 冷启动并发下,Pipeline Cache 为什么必须合并在途编译
ios·swift
weixin_403810131 天前
用 Python 调 EasyClick iOS 免越狱脚本:开放接口对接步骤与参数
ios·iphone