Как пользовательская клавиатура Google, Gboard, программно закрывает самое переднее приложение?

Пользовательское приложение Google для iOS, Gboard, имеет интересную функцию, которую невозможно реализовать с помощью общедоступных API для iOS SDK ( начиная с iOS 10). Я хотел бы точно знать, как Google выполняет задачу программного возврата одного приложения в стек переключения приложений в Gboard.

У пользовательских клавиатур iOS есть два основных компонента: приложение контейнера и расширение приложения клавиатуры. Расширение приложения клавиатуры запускается в отдельном процессе ОС, который запускается всякий раз, когда пользователь находится в любом приложении на своем телефоне, которое требует ввода текста.

Вот примерные шаги, которые можно выполнить, используя Gboard, чтобы увидеть эффект программного возврата к предыдущему приложению:

  1. Пользователь запускает приложение Apple Messages на своем iPhone и нажимает на текстовое поле, чтобы начать ввод текста.
  2. Расширение клавиатуры Gboard запущено, и пользователи видят пользовательскую клавиатуру Gboard (пока они все еще находятся в приложении Apple Messages).
  3. Пользователь нажимает клавишу микрофона внутри расширения клавиатуры Gboard, чтобы сделать голосовой ввод текста.
  4. Gboard использует собственную схему URL для запуска приложения контейнера Gboard. Клавиатура Gboard и приложение сообщений Apple помещаются на один уровень в стеке приложений, и приложение-контейнер Gboard теперь является самым передним приложением в стеке приложений. Контейнерное приложение Gboard использует микрофон для прослушивания речи пользователя и переводит ее в текст, который он помещает на экран.
  5. Пользователь нажимает кнопку "Готово", когда он удовлетворен вводом текста, который он видит на экране.
  6. Вот где происходит волшебство... когда экран ввода текста закрывается, приложение контейнера Gboard также автоматически закрывается. Контейнерное приложение Gboard исчезает и заменяется приложением Apple Messages (иногда процесс расширения клавиатуры Gboard все еще активен, иногда он перезапускается, а иногда его необходимо перезапустить вручную, нажав внутри текстового поля.) . Как Google достигает этого?
  7. Наконец, пользователь видит, что только что переведенный текст автоматически вставляется в поле ввода текста. Предположительно Google выполняет эту задачу, обмениваясь данными между приложением-контейнером Gboard и расширением клавиатуры.

Я предположил бы, что Google использует частные API, исследуя иерархию представления строки состояния, используя интроспекцию Objective-C во время выполнения и каким-то образом синтезируя события касания или вызывая выставленную цель / действие. Я очень мало это изучил и смог найти интересные подклассы UIView внутри строки состояния, такие как UIStatusBarBreadcrumbItemView, который содержит массив UISystemNavigationAction s. Я продолжаю исследовать эти классы в надежде, что смогу найти какой-нибудь способ репликации взаимодействия с пользователем.

Я понимаю, что использование частных API - хороший способ отклонить отправку вашего приложения из App Store - это не проблема, на которую я бы хотел ответить в ответе. В первую очередь я ищу конкретные ответы о том, как именно Google выполняет задачу программного возврата одного приложения в стек переключения приложений в Gboard.

2 ответа

Решение

Ваше предположение верно - Gboard использует закрытый API для этого.

… Хотя не через изучение иерархии представлений или внедрение событий.

Когда действие преобразования текста в текст выполнено, мы можем проверить системный журнал из Xcode или Console, который он вызывает -[AVAudioSession setActive:withOptions:error:] метод. Поэтому я перепроектировал приложение Gboard и поищу трассировку стека, связанную с этим.

Поднявшись по стеку вызовов, мы можем найти -[GKBVoiceRecognitionViewController navigateBackToPreviousApp] метод и…

введите описание изображения здесь

... _systemNavigationAction ? Да, безусловно, закрытый API.

поскольку class_getInstanceVariable это публичный API и "_systemNavigationAction" является строковым литералом, автоматическая проверка не в состоянии заметить использование частного API, и рецензенты, вероятно, не видят ничего плохого в поведении "перейти к предыдущему приложению". Или, возможно, потому что они Google, а вы нет...


Фактический код, который выполняет действие "Перейти к предыдущему приложению", выглядит следующим образом:

@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: Метод выполняет фактическое действие "назад".

(Поскольку API недокументирован, Gboard на самом деле использует API неправильно. Они использовали неверную подпись -(void)sendResponseForDestination:(id)destination, Но бывает так, что все числа, кроме 1 будет работать так же, поэтому разработчикам гугл на этот раз повезло)

Быстрая версия ответа @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
}
Другие вопросы по тегам