Google 的自定义 iOS 键盘 Gboard 如何以编程方式关闭最前面的应用程序?

How does Google's custom iOS keyboard, Gboard, programmatically dismiss the frontmost app?

Google 的自定义 iOS 应用程序 Gboard 有一个有趣的功能,在 iOS 中使用 public API 无法实现] SDK(截至 iOS 10)。 我想确切地知道 Google 如何以编程方式在 Gboard 的应用程序切换堆栈中完成弹出一个应用程序的任务。

自定义 iOS 键盘有两个主要组件:容器应用程序和键盘应用程序扩展。键盘应用程序扩展在单独的 OS 进程中运行,每当用户在其 phone 上需要文本输入的任何应用程序中时启动该进程。

这些是使用 Gboard 可以遵循的大致步骤,以查看以编程方式返回到以前的应用程序的效果:

  1. 用户在其 iPhone 上启动 Apple Messages 应用程序并点击文本字段开始输入文本。
  2. Gboard 键盘扩展程序已启动,用户会看到 Gboard 自定义键盘(同时他们仍在 Apple Messages 应用程序中)。
  3. 用户点击 Gboard 键盘扩展中的微型 phone 键进行语音到文本的输入。
  4. Gboard 使用 custom url scheme 启动 Gboard 容器应用。 Gboard 键盘和 Apple 消息应用程序在应用程序堆栈中被下推一层,Gboard 容器应用程序现在是应用程序堆栈中最前面的应用程序。 Gboard 容器应用使用 microphone 来收听用户的语音并将其翻译成放置在屏幕上的文本。
  5. 当用户对他们在屏幕上看到的文本输入感到满意时,他们会点击 "Done" 按钮。
  6. 这就是奇迹发生的地方……当文本输入屏幕关闭时,Gboard 容器应用程序也会自动关闭。 Gboard 容器应用程序消失并被 Apple Messages 应用程序取代(有时 Gboard 键盘扩展进程仍然存在,有时它会重新启动,有时需要通过在文本字段内点击来手动重新启动。)。 Google 是如何做到这一点的?
  7. 最后,用户看到刚刚翻译的文本自动插入到文本输入字段中。据推测,Google 通过 Gboard 容器应用程序和键盘扩展之间的 sharing data 实现了这一点。

我假设 Google 通过使用 Objective-C 运行时内省探索状态栏的视图层次结构并以某种方式合成点击事件或调用公开的目标/操作来使用私有 API。我对此进行了一些探索,并且能够在状态栏中找到有趣的 UIView sub类,例如 UIStatusBarBreadcrumbItemView which contains an array of UISystemNavigationActions。我将继续探索这些 类,希望我能找到一些复制用户交互的方法。

我知道使用私有 API 是让您的应用程序提交被 App Store 拒绝的好方法 - 这不是我希望在答案中解决的问题。我主要是在寻找有关 Google 如何以编程方式在 Gboard 的 App Switching 堆栈中弹出一个应用程序的任务的具体答案。

您的猜测是正确的 — Gboard 正在使用私有 API 来做到这一点。

…虽然不是通过探索视图层次结构或事件注入。

完成语音转文本操作后,我们可以从 Xcode 或控制台检查它调用 -[AVAudioSession setActive:withOptions:error:] 方法的系统日志。所以我对 Gboard 应用程序进行了逆向工程,并寻找与此相关的堆栈跟踪。

向上攀登调用堆栈,我们可以找到 -[GKBVoiceRecognitionViewController navigateBackToPreviousApp] 方法,并且...

_systemNavigationAction? 是的,绝对是私人的 API。

因为 class_getInstanceVariable 是 public API 而 "_systemNavigationAction" 是字符串文字,自动检查器无法记录私有 API 用法,而且人工审阅者可能看不出 "jump back to the previous app" 行为有任何问题。或者可能是因为他们是 Google 而你不是……


执行"jump back to previous app"动作的实际代码是这样的:

@import UIKit;
@import ObjectiveC.runtime;

@interface UISystemNavigationAction : NSObject
@property(nonatomic, readonly, nonnull) NSArray<NSNumber*>* destinations;
-(BOOL)sendResponseForDestination:(NSUInteger)destination;
@end

inline BOOL jumpBackToPreviousApp() {
    Ivar sysNavIvar = class_getInstanceVariable(UIApplication.class, "_systemNavigationAction");
    UIApplication* app = UIApplication.sharedApplication;
    UISystemNavigationAction* action = object_getIvar(app, sysNavIvar);
    if (!action) {
        return NO;
    }
    NSUInteger destination = action.destinations.firstObject.unsignedIntegerValue;
    return [action sendResponseForDestination:destination];
}

特别是,-sendResponseForDestination: 方法执行实际的 "go back" 操作。

(由于 API 未记录,Gboard 实际上使用 API 不正确 。他们使用了错误的签名 -(void)sendResponseForDestination:(id)destination。但是碰巧除 1 之外的所有数字都将工作相同,所以 Google 开发人员这次很幸运)

Swift @kennytm 的版本 回答:

@objc private protocol PrivateSelectors: NSObjectProtocol {
    var destinations: [NSNumber] { get }
    func sendResponseForDestination(_ destination: NSNumber)
}

func jumpBackToPreviousApp() -> Bool {
    guard
        let sysNavIvar = class_getInstanceVariable(UIApplication.self, "_systemNavigationAction"),
        let action = object_getIvar(UIApplication.shared, sysNavIvar) as? NSObject,
        let destinations = action.perform(#selector(getter: PrivateSelectors.destinations)).takeUnretainedValue() as? [NSNumber],
        let firstDestination = destinations.first
    else {
        return false
    }
    action.perform(#selector(PrivateSelectors.sendResponseForDestination), with: firstDestination)
    return true
}