乐趣区

[App探索]JSBox中幽灵触发器的实现原理探索

前言
幽灵触发器是钟颖大神的 JSBox 中的一个功能,在 app 进程被杀死的情况下,也可以将通知固定在通知栏,即便用户点击清除,也能马上再弹出,永远不消失,除非用户关闭 App 的通知权限或者卸载 App,才可以消失。这个功能确实比较有意思,而且钟颖大神在介绍视频里有提到是目前 JSBox 独有的,说明实现得非常巧妙,自己研究的话还是很难想到的,非常值得学习,而且当你了解它的实现原理的话,会发现其实可以做很多其他的事情。当某天产品经理对 App 推送点击率不满意时,可以向她祭出这件大杀器 (哈哈,开玩笑的,无线推送这种功能其实苹果很不推荐,因为确实有可能会被一些不良 App 采用,然后无限推送,让用户反感)。以下内容仅供学习讨论,JSBox 是一个很强大的 App,有很多值得学习的地方,强烈推荐大家去购买使用。
简短的效果视频

完整的介绍视频
https://weibo.com/tv/v/G79vjv…:1f37179499e39dbc8a7472897b9e056c 从 2 分 6 秒开始
探索历程
因为没有可以用来砸壳的越狱手机,而且 PP 助手也没有 JSBox 的包,一开始是去搜幽灵触发器,无限通知的实现,发现没找到答案,stackoverflow 上的开发者倒是对无限通知比较感兴趣,问答比较多,但是没有人给出答案,基本上也是说因为苹果不希望开发者用这种功能去骚扰用户。所以只能自己阅读通知文档,查资料来尝试实现了。
难道是使用时间间隔触发器 UNTimeIntervalNotificationTrigger 来实现的吗?
因为看通知清除了还是一个接一个得出现,很自然就能想到是通过绕过苹果的检测,去改 UNTimeIntervalNotificationTrigger 的 timeInterval 属性来实现的,所以写出了一下代码:
UNTimeIntervalNotificationTrigger *timeTrigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:1.0f repeats:YES];
UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init];
content.title = @” 推送标题 ”;
UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:@”requestIdentifier” content:content trigger:timeTrigger];
[center addNotificationRequest:request withCompletionHandler:nil];
通过传入创建时间间隔为 1s 的实际间隔触发器来实现,运行后,第一个通知能正常显示出来,清除第一个通知后,显示第二个通知时,app 崩溃了,时间间隔不能小于 60s。
UserNotificationsDemo[14895:860379] *** Terminating app due to uncaught exception ‘NSInternalInconsistencyException’, reason: ‘time interval must be at least 60 if repeating’
*** First throw call stack:
(0x1ae2a3ea0 0x1ad475a40 0x1ae1b9c1c 0x1aeca7140 0x1b8738d0c 0x1b8738bdc 0x102d508ac 0x1db487658 0x1dad09a18 0x1dad09720 0x1dad0e8e0 0x1dad0f840 0x1dad0e798 0x1dad13684 0x1db057090 0x1b0cd96e4 0x1030ccdc8 0x1030d0a10 0x1b0d17a9c 0x1b0d17728 0x1b0d17d44 0x1ae2341cc 0x1ae23414c 0x1ae233a30 0x1ae22e8fc 0x1ae22e1cc 0x1b04a5584 0x1db471054 0x102d517f0 0x1adceebb4)
libc++abi.dylib: terminating with uncaught exception of type NSException
timeInterval 是只读属性,看来苹果早有防范 `@property (NS_NONATOMIC_IOSONLY, readonly) NSTimeInterval timeInterval;` 但是这年头,还能活着做 iOS 开发的谁没还不会用 KVC 呀,所以很自然得就能想到使用 KVC 来改
UNTimeIntervalNotificationTrigger *timeTrigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:1.0f repeats:YES];
UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init];
content.title = @” 推送标题 ”;
UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:@”requestIdentifier” content:content trigger:timeTrigger];
[timeTrigger setValue:@1 forKey:@”timeInterval”];
[center addNotificationRequest:request withCompletionHandler:nil];
而且我打断点看,确实改成功了,但是,很快,当我把第一个通知清除时,手机变成这样了有那么一刻,我心里很慌,我一定好好做人,不去改苹果爸爸的只读属性了。
苹果是在显示第二个通知的时候才去判断的,而我们的代码只能控制到将通知请求 request 添加到 UNUserNotificationCenter 这一步,所以不太好绕过。
难道是使用地点触发器 UNLocationNotificationTrigger 来实现的吗?
UNLocationNotificationTrigger 可以通过判断用户进入某一区域,离开某一区域时触发通知,但是我去看了一下设置里面的权限,发现只使用这个功能的时候 JSBox 并没有请求定位的权限,所以应该不是根据地点触发的。
继续阅读文档
然后我就去钟颖大神的 JSBox 社区仔细查看开发者文档,查看关于通知触发相关的 api,结果发现不是通过 repeats 字段,而是通过 renew 这个字段来决定是否需要重复创建通知的,所以很有可能不是通过时间触发器来实现的,是通过自己写代码去创建一个通知,然后将通知进行发送。在大部分 iOS 开发同学心中 (包括我之前也是这么认为的),普遍都认为当 app 处于运行状态时,这样的实现方案自然没有问题,因为我们可以获取到通知展示,用户对通知操作的回调。当 app 处于未运行状态时,除非用户点击通知唤醒 app,我们无法获取到操作的回调,但其实在 iOS 10 以后,苹果公开的 UserNotifications 框架,允许开发者通过实现 UNUserNotificationCenter 的代理方法,来处理用户对通知的各种点击操作。具体可以看苹果的这篇文章 Handling Notifications and Notification-Related Actions,翻译其中主要的一段:你可以通过实现 UNUserNotificationCenter 的代理方法,来处理用户对通知的各种点击操作。当用户对通知进行某种操作时,系统会在后台启动你的 app 并且调用 UNUserNotificationCenter 的代理对象实现的 userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler: 方法,参数 response 中会包含用户进行的操作的 actionIdentifier,即便是系统定义的通知操作也是一样,当用户对通知点击取消或者点击打开唤醒 App,系统也会上报这些操作。核心就是这个方法
// The method will be called on the delegate when the user responded to the notification by opening the application, dismissing the notification or choosing a UNNotificationAction. The delegate must be set before the application returns from application:didFinishLaunchingWithOptions:.
– (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void(^)(void))completionHandler __IOS_AVAILABLE(10.0) __WATCHOS_AVAILABLE(3.0) __OSX_AVAILABLE(10.14) __TVOS_PROHIBITED;
所以我就写了一个 demo 来实现这个功能,核心代码如下:
AppDelegate.m

– (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Override point for customization after application launch.
[[UIApplication sharedApplication] setApplicationIconBadgeNumber:0];
[self applyPushNotificationAuthorization:application];// 请求发送通知授权
[self addNotificationAction];// 添加自定义通知操作扩展
return YES;
}
// 请求发送通知授权
– (void)applyPushNotificationAuthorization:(UIApplication *)application{
if (([[[UIDevice currentDevice] systemVersion] floatValue] >= 10.0)) {
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
center.delegate = self;
[center requestAuthorizationWithOptions:(UNAuthorizationOptionBadge | UNAuthorizationOptionSound | UNAuthorizationOptionAlert) completionHandler:^(BOOL granted, NSError * _Nullable error) {
if (!error && granted) {
NSLog(@” 注册成功 ”);
}else{
NSLog(@” 注册失败 ”);
}

}];
[center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings * _Nonnull settings) {
NSLog(@”settings========%@”,settings);
}];
} else if (([[[UIDevice currentDevice] systemVersion] floatValue] >= 8.0)){
[[UIApplication sharedApplication] registerUserNotificationSettings:[UIUserNotificationSettings settingsForTypes:(UIUserNotificationTypeAlert | UIUserNotificationTypeBadge | UIUserNotificationTypeSound) categories:nil]];
}
[application registerForRemoteNotifications];
}

// 添加自定义通知操作扩展
– (void)addNotificationAction {
UNNotificationAction *openAction = [UNNotificationAction actionWithIdentifier:@”NotificationForeverCategory.action.look” title:@” 打开 App” options:UNNotificationActionOptionForeground];
UNNotificationAction *cancelAction = [UNNotificationAction actionWithIdentifier:@”NotificationForeverCategory.action.cancel” title:@” 取消 ” options:UNNotificationActionOptionDestructive];
UNNotificationCategory *notificationCategory = [UNNotificationCategory categoryWithIdentifier:@”NotificationForeverCategory” actions:@[openAction, cancelAction] intentIdentifiers:@[] options:UNNotificationCategoryOptionCustomDismissAction];
[[UNUserNotificationCenter currentNotificationCenter] setNotificationCategories:[NSSet setWithObject:notificationCategory]];
}

# pragma mark UNUserNotificationCenterDelegate
//app 处于前台时,通知即将展示时的回调方法,不实现会导致通知显示不了
– (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler{
completionHandler(UNNotificationPresentationOptionBadge|
UNNotificationPresentationOptionSound|
UNNotificationPresentationOptionAlert);
}

//app 处于后台或者未运行状态时,用户点击操作的回调
– (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void(^)(void))completionHandler {
[[UIApplication sharedApplication] setApplicationIconBadgeNumber:0];
if ([response.actionIdentifier isEqualToString:UNNotificationDismissActionIdentifier]) {// 点击系统的清除按钮
UNTimeIntervalNotificationTrigger *timeTrigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:0.0001f repeats:NO];
UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init];
content.title = @”App 探索 -NotFound”;
content.body = @”[App 探索]JSBox 中幽灵触发器的实现原理探索 ”;
content.badge = @1;
content.categoryIdentifier = @”NotificationForeverCategory”;
UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:response.notification.request.identifier content:content trigger:timeTrigger];
[[UNUserNotificationCenter currentNotificationCenter] addNotificationRequest:request withCompletionHandler:nil];
}
completionHandler();
}

– (void)applicationWillResignActive:(UIApplication *)application {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
}

– (void)applicationDidEnterBackground:(UIApplication *)application {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}

– (void)applicationWillEnterForeground:(UIApplication *)application {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
}

– (void)applicationDidBecomeActive:(UIApplication *)application {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}

– (void)applicationWillTerminate:(UIApplication *)application {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}

ViewController.m
– (void)viewDidLoad {
[super viewDidLoad];
UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem];
[button addTarget:self action:@selector(sendNotification) forControlEvents:UIControlEventTouchUpInside];
[button setTitle:@” 发送一个 3s 后显示的通知 ” forState:UIControlStateNormal];
button.frame = CGRectMake(0, 200, [UIScreen mainScreen].bounds.size.width, 100);
[self.view addSubview:button];
}

// 发送一个通知
– (void)sendNotification {
UNTimeIntervalNotificationTrigger *timeTrigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:3.0f repeats:NO];
UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init];
content.title = @”App 探索 -NotFound”;
content.body = @”[App 探索]JSBox 中幽灵触发器的实现原理探索 ”;
content.badge = @1;
content.categoryIdentifier = @”NotificationForeverCategory”;
UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:@”requestIdentifier” content:content trigger:timeTrigger];
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center addNotificationRequest:request withCompletionHandler: nil];
}

必须在 didFinishLaunchingWithOptions 的方法返回前设置通知中心的代理,这个文档里面都有提及,大家都知道,但是有两个文档里面未曾提及的难点需要注意:
隐藏关卡一 必须给通知添加自定义的通知操作
1. 必须给通知添加自定义的通知操作,并且给发送的通知指定自定义的通知操作的 categoryIdentifier,这样系统在用户对通知进行操作时才会调用这个代理方法,- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void(^)(void))completionHandler 自定义通知操作是用户长按通知,下方弹出的 actionSheet,在我们的 Demo 中,是“打开 App”和“取消”两个操作,其实不添加这些自定义操作的话,系统的这些“管理”,”“查看”,“清除”也是有的,但是当用户点击“清除”时,我们的代理方法 didReceiveNotificationResponse 就不会被调用了,文档里面没有提及这个,我也是试了好久才试出来的。
隐藏关卡二 必须使用上一个通知的 requestIdentifier
当用户点击“清除”按钮时,即便 app 处于未运行状态,系统也会在后台运行我们的 app,并且执行 didReceiveNotificationResponse 这个代理方法,在这个方法里面我们会创建一个 UNNotificationRequest,把他添加到通知中心去,然后通知会展示出来。但是系统好像对于在 app 正常运行时添加的 UNNotificationRequest 跟在 didReceiveNotificationResponse 方法里添加的 UNNotificationRequest 做了区分,后者在被用户点击“清除”按钮后,app 不会收到 didReceiveNotificationResponse 回调方法,可能系统也是考虑到开发者可能会利用这个机制去实现无限通知的功能。所以我在创建 UNNotificationRequest 时,使用的 identifier 是前一个通知的 identifier,这也是实现无限通知的最巧妙的地方,可能很多开发者是知道实现这个代理方法来接受用户点击“清除”的回调,然后做一些通知上报,隔一段时间再次发送通知事情,但是再次创建并发送的通知在被点击“清除”时已经不会再执行 didReceiveNotificationResponse 回调了。

UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:response.notification.request.identifier content:content trigger:timeTrigger];
扩展
如果我们做的是效率工具类型的 App,利用这个功能做一些固定通知之类的功能,如果我们做的是一些资讯类的 App,可以做一些不定间隔推送的功能,而不需要每次用户点击“清除”后,将用户操作通过网络请求上报给服务器,然后服务器根据情况给用户发推送。更多的玩法有待我们探索。
Demo https://github.com/577528249/…

Demo 演示 Gif

写文章太耗费时间了,可以的话,求大家给我点个关注吧,会定期写原创文章,谢谢了!

退出移动版