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

本文已收录到1.1K Star数开源学习指南——《大厂面试指北》,如果想要了解更多大厂面试相关的内容及获取《大厂面试指北》离线PDF版,请扫描下方二维码码关注公众号“大厂面试”,谢谢大家了!

《大厂面试指北》最佳阅读地址:

http://notfound9.github.io/interviewGuide/

《大厂面试指北》项目地址:

https://github.com/NotFound9/interviewGuide

获取《大厂面试指北》离线PDF版,请扫描下方二维码关注公众号“大厂面试”

image.png

《大厂面试指北》项目截图:

image.png

前言

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

简短的效果视频

image

完整的介绍视频

https://weibo.com/tv/v/G79vjviDM?fid=1034: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];

而且我打断点看,确实改成功了,


image.png

但是,很快,当我把第一个通知清除时,手机变成这样了


image

有那么一刻,我心里很慌,我一定好好做人,不去改苹果爸爸的只读属性了。

苹果是在显示第二个通知的时候才去判断的,而我们的代码只能控制到将通知请求request添加到UNUserNotificationCenter这一步,所以不太好绕过。

难道是使用地点触发器UNLocationNotificationTrigger来实现的吗?

UNLocationNotificationTrigger可以通过判断用户进入某一区域,离开某一区域时触发通知,但是我去看了一下设置里面的权限,发现只使用这个功能的时候JSBox并没有请求定位的权限,所以应该不是根据地点触发的。

继续阅读文档

然后我就去钟颖大神的JSBox社区仔细查看开发者文档,查看关于通知触发相关的api,结果发现

image.png

不是通过repeats字段,而是通过renew这个字段来决定是否需要重复创建通知的,所以很有可能不是通过时间触发器来实现的,是通过自己写代码去创建一个通知,然后将通知进行发送。
在大部分iOS开发同学心中(包括我之前也是这么认为的),普遍都认为当app处于运行状态时,这样的实现方案自然没有问题,因为我们可以获取到通知展示,用户对通知操作的回调。当app处于未运行状态时,除非用户点击通知唤醒app,我们无法获取到操作的回调,但其实在iOS 10以后,苹果公开的UserNotifications框架,允许开发者通过实现UNUserNotificationCenter的代理方法,来处理用户对通知的各种点击操作。具体可以看苹果的这篇文章Handling Notifications and Notification-Related Actions
翻译其中主要的一段:
image.png

你可以通过实现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就不会被调用了,文档里面没有提及这个,我也是试了好久才试出来的。

image.png

image.png

隐藏关卡二 必须使用上一个通知的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/NotificationForever

Demo 演示Gif

[图片上传失败...(image-fe3c57-1546414668879)]

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

PS:
之前建了一个微信群,里面技术氛围比较浓厚,会经常讨论一些水平比较高的技术问题,包括本文的幽灵触发器的实现也是群里的一位同学提出,我发现网上没有相关的实现,花了几天时间研究出来的。欢迎更多对技术有热情的同学加入我们,可以加我微信ruiwendelll,我拉大家进群。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容