Tired of outsourcing? Get hired at a top product startup from Silicon Valley 🚀
×Закрыть

Применение GameplayKit Randomization и State Machine в iOS-проектах

В предыдущей статье было описано, как применять игровой 2D-движок SpriteKit для быстрого создания простых анимаций в iOS. В новой статье я хочу поделиться, как использовать GameplayKit в неигровых приложениях.

GameplayKit — это набор инструментов, который Apple представляет для быстрого конструирования игровых процессов и алгоритмов. Рассмотрим инструменты, которые применимы даже в UIKit/Appkit-проектах.

Randomization

Так называется инструмент, позволяющий применять различные алгоритмы рандома, которые довольно часто приходится использовать в играх. Здесь не будет обсуждаться генерация рандомных чисел для создания секретных ключей шифрования, так как даже в самой документации у Apple указано, что эти сервисы рандомизации не являются криптографически устойчивыми, и для таких целей рекомендуется применять совсем другие инструменты.

Раньше чаще всего многие применяли метод random() или arc4random(), построенный на ARC4-алгоритме и генерирующий числа между 0 и 4294967295. После выхода Swift 4.2 появились новые методы для генерации рандома:

let randomInt = Int.random(in: 0..<10)
let randomDouble = Double.random(in: 5.71838...6.15249)
let randomBool = Bool.random()

Обычно этих инструментов достаточно, если нам нужно просто сгенерировать случайное число, не задумываясь о последствиях. В таких случаях вы никак не сможете влиять на алгоритм рандомизации, последовательность и частоту выпадения определенных значений. А если попытаться влиять на этот процесс, производя генерацию несколько раз, чтобы получать нужный range значений, и еще это нужно делать на каждый кадр, то производительность работы может сильно пострадать. Такая ситуация обусловлена тем, что в современных играх часто происходит одновременная генерация нескольких случайных чисел за один кадр и, таким образом, чтобы поддерживать 60 кадров в секунду, придется несколько десятков, а иногда и сотен раз в секунду инициализировать генерацию и обработку случайных чисел.

Такой подход имеет и проблемы в создании последовательности одинаковых чисел у двух и более пользователей, особенно если эти люди используют различные платформы, код которых написан на других языках программирования.

Именно для этого и применяется Randomization из GameplayKit, позволяя сделать генерацию более детерминированной.

Random Source

Собственно, весь процесс рандома состоит из объекта-суперкласса GKRandomSource, который является источником рандомных чисел (Random Source), а также наследования от протокола GKRandom.

Сам протокол GKRandom представляет минимальный интерфейс для генерации случайных чисел и состоит всего из 4 методов:

let randomSource = GKRandomSource.sharedRandom()
// возвращает случайное значение Int32.min и Int32.max
// диапазон чисел от -2 147 483 648 до 2 147 483 647 
randomSource.nextInt()
// возвращает случайное значение Int между 0 и 9
randomSource.nextInt(upperBound: 10)
// возвращает случайное Float значение в диапазоне от 0.0 до 1.0
randomSource.nextUniform()
// возвращает случайное Bool
randomSource.nextBool()

GameplayKit предлагает один базовый и 3 альтернативных Random Source, которые являются детерминированными и могут быть сериализованы с использованием NSCoding, чтобы, к примеру, была возможность сохранить текущее состояние последовательности.

  • GKRandomSource — базовый генератор случайных чисел, от которого наследуются все последующие Random Source классы.
  • GKARC4RandomSource — генератор случайных чисел, реализующий уже привычный в iOS алгоритм ARC4 (arc4random). Особенность также состоит в том, что у этого источника есть метод dropValues(_:), который помогает отбросить определенное количество первых последовательностей, чтобы было сложнее предугадать вероятное следующее значение.
let arc4 = GKARC4RandomSource()
// Минимальное рекомендуемое количество отбрасываемых значений в последовательности
arc4.dropValues(768)
// Генерация случайного числа от 0 до 10
arc4.nextInt(upperBound: 11)
  • GKLinearCongruentialRandomSource — генератор чисел, реализующий алгоритм линейного конгруэнтного генератора, который быстрее, но менее случайный, чем стандартный ARC4. Основное преимущество его в том, что этот алгоритм есть в стандартных библиотеках некоторых языков программирования. Поэтому иногда его можно применять для создания одинаковой последовательности случайных чисел на разных платформах. К примеру, в Java этот алгоритм используется в java.util.Random. Также его стоит применять в том случае, если вы действительно делаете десятки или сотни генераций в секунду, иначе разница в производительности будет практически незаметна.
let linearCongruential = GKLinearCongruentialRandomSource()
// Генерация случайного числа от 0 до 10
linearCongruential.nextInt(upperBound: 11)
  • GKMersenneTwisterRandomSource — генератор случайных чисел, реализующий алгоритм вихрь Мерсенна, разработанный японскими учеными, который является более случайным, но и менее производительным, чем ARC4. Реализован в стандартных библиотеках: C++, Python, Ruby, PHP.
let mersenneTwister = GKMersenneTwisterRandomSource()
mersenneTwister.nextInt(upperBound: 11)

Очень удобно, что все эти источники имеют одинаковый интерфейс и не нужно каждый раз изучать специфику использования каждого в отдельности.

За счет того, что все эти классы наследуются от GKRandomSource, который является суперклассом для всех представленных алгоритмов, это позволяет создавать сразу все генераторы независимыми друг от друга и в то же время детерминированными. При этом мы можем легко производить репликацию с сохранением последовательности каждого из алгоритмов.

Random Distribution

Еще одним важным преимуществом рандомизации через GameplayKit является возможность формировать Random Source вместе с Random Distribution (методом случайного распределения).

Всего нам представлено 3 класса для Random Distribution:

  • GKRandomDistribution — распределение, где равномерная вероятность генерации любого числа в указанном диапазоне приблизительно равнозначна. Таким образом, исключается предвзятость в отношении любого возможного результата. Что приятно, этот класс имеет удобный интерфейс, чтобы сразу инициализировать аналог 6-гранного кубика, или 20-гранного кубика, или даже 100-гранного кубика.
// Это также можно сделать через GKRandomDistribution.d6()
let 🎲 = GKRandomDistribution(forDieWithSideCount: 6)
(1...10).forEach { _ in print(🎲.nextInt()) }
// 5 1 6 2 1 4 3 1 4 6
 
// Реализация 20 гранного кубика
let d20 = GKRandomDistribution.d20()
d20.nextInt() 
 
// Реализация 100 гранного кубика
let d100 = GKRandomDistribution(lowestValue: 1, highestValue: 100)
d100.nextInt()

Такой подход очень похож на использование Int.random(in:), но здесь основное отличие в том, что можно заранее инициализировать GKRandomDistribution, а затем заново использовать его сколько угодно, не задавая каждый раз необходимый диапазон чисел. В текущем примере при реализации распределения будет использоваться алгоритм ARC4 для генерации последовательности. Чтобы переопределить Random Source, достаточно просто инициализировать Random Distribution с указанием нужного источника.

let linearCongruential = GKLinearCongruentialRandomSource()
let 🎲 = GKRandomDistribution(randomSource: linearCongruential,
                                        lowestValue: 1,
                                        highestValue: 6)
🎲.nextInt()
  • GKGaussianDistribution — генератор, который реализует распределение Гаусса (нормальное распределение) по множественным выборкам. Если коротко, то такой алгоритм рандома позволяет чаще получать средние значения в указанном вами интервале минимального и максимального значения. Например, в приложении нужно пользователю ежедневно выдавать бонус за использование, и такой алгоритм подойдет, чтобы всегда предоставлять усредненное значение. Или в играх, когда необходимо генерировать юниты, которые почти всегда будут с усредненными характеристиками.
let random = GKRandomSource()
// В этом примере, представим что у нас 10 гранный кубик, чтобы лучше было видно разброс чисел
let 🎲 = GKGaussianDistribution(randomSource: GKRandomSource(),
                                  lowestValue: 1,
                                  highestValue: 10)
(1...10).forEach { _ in print(🎲.nextInt()) }  // Бросаем кубик 10 раз
// 7 8 5 4 5 7 6 5 5 4

Также здесь мы можем влиять на рандомизацию, изменяя ожидаемое среднее значение mean и шаг интервала deviation. Возьмем пример, где среднее ожидаемое значение кубика будет 3, а шаг интервала 1:

let 🎲 = GKGaussianDistribution(randomSource: GKRandomSource(),
                                  mean: 3,
                                  deviation: 1)
(1...10).forEach { _ in print(🎲.nextInt()) }
// 2 3 3 3 2 2 3 4 2 2

В итоге получается, что около 68% сгенерированных чисел находятся в пределах одного отклонения от значения mean, 95% — в пределах 2 отклонений и почти 100% — в пределах 3 отклонений.

  • GKShuffledDistribution — генератор чисел, которые равномерно распределены по множеству выборок, но где короткие последовательности схожих значений исключены. Таким образом, если у нас будет указана генерация чисел от 1 до 5, то значение 5 выпадет во второй раз только после того, как все остальные числа от 1 до 4 точно так же выпадут по одному разу. Чаще всего подобную реализацию мы можем встретить в плей-листах современных аудиоплееров.
// Альтернативная инициализация диапазона чисел как у 6 гранного кубика
let 🎲 = GKShuffledDistribution.d6()
(1...7).forEach { _ in print(🎲.nextInt()) }  // Бросаем кубик 7 раз
// 4 5 3 1 2 6 4

Как можно видеть, здесь значение грани с числом 4 повторяется только после того, как выпадут все остальные значения.

Что интересно, метод shuffle() распределения элементов в массиве, который был добавлен в Swift только в версии 4.2, все это время был доступен в GameplayKit Randomization еще с iOS 9: arrayByShufflingObjects(in:). Работают они, естественно, на одном алгоритме Фишера — Йетса. Но основное отличие между ними только в том, что GameplayKit возвращает новый массив, в то время как реализация в Swift перемешивает оригинальный.

Контроль последовательности рандома

Некоторые показанные мною примеры генерировали значения с использованием конкретного алгоритма и указанным способом распределения, но при этом можно еще влиять на последовательность этих случайных чисел. В итоге получается, что этот рандом будет не таким уж и рандомным :)

Это может понадобиться, когда необходимо сделать одинаковую последовательность рандома на разных платформах или когда тестировщикам требуется повторить определенную последовательность. Все показанные мною GKRandomSource-классы (кроме базового) используют свойство seed, которое доступно для изменения. Зная значение seed, вы можете узнать всю последовательность рандома.

let seed: UInt64 = 123
let randomSource1 = GKMersenneTwisterRandomSource(seed: seed)
let 🎲1 = GKRandomDistribution.d6()
(1...7).forEach { _ in print(🎲1.nextInt()) }
// 6 2 5 3 2 3 6
 
let randomSource2 = GKMersenneTwisterRandomSource(seed: seed)
let 🎲2 = GKRandomDistribution.d6()
(1...7).forEach { _ in print(🎲2.nextInt()) }
// 6 2 5 3 2 3 6

State Machine

В iOS уже давно была реализована State Machine, которую вполне можно применять, даже в обычных UIKit-проектах. И при этом не потребуется использовать Rx, NotificationCenter, OperationQueue или создавать огромные Enum’s.

State Machine в GameplayKit имеет простой интерфейс и состоит всего из 2 классов:

  • GKState — абстрактный класс, от которого мы наследуемся, чтобы создать отдельный объект конкретного состояния. Каждый такой класс определяет новое другое State-состояние, в которое он можете перейти.
  • GKStateMachine — сама State-машина, содержащая в себе объекты состояний, которые наследуются от GKState.

Создаем свою State Machine

В качестве примера использования UIKit в приложении я покажу, как это можно применить, например при загрузке какого-либо файла на сервер.

Всего будет 3 состояния: Uploading, Success, Failure.

Uploading State

final class UploadingDataState: GKState {
    
    private let viewController: UploadViewController
    
    init(_ viewController: UploadViewController) {
        self.viewController = viewController
    }
    
    override func isValidNextState(_ stateClass: AnyClass) -> Bool {
        // Здесь мы указываем, каким может быть следующий State
        return stateClass == SuccessfulState.self ||
            stateClass ==  FailureState.self
    }
 
// Метод который вызывается когда State Machine успешно перешла к этому состоянию
    override func didEnter(from previousState: GKState?) {
        // отображаем анимацию загрузки пока находимся в этом состоянии
        viewController.activityIndicator.startAnimating()
        
        // пример вызова какой-то реализации API запроса 
        API.fetchData { result in
            switch result {
            case .success:
                stateMachine?.enter(SuccessfulState.self)
            case .failure:
                stateMachine?.enter(FailureState.self)
            }
        }
    }
 
// Метод который вызывается когда State Machine переходит к другому состоянию
    override func willExit(to nextState: GKState) {
        // убираем анимацию загрузки когда покидаем состояние
        viewController.activityIndicator.stopAnimating()
    }
}

Successful and Failure State

final class SuccessfulState: GKState {
    
    private let viewController: UploadViewController
    
    init(_ viewController: UploadViewController) {
        self.viewController = viewController
    }
    
    override func isValidNextState(_ stateClass: AnyClass) -> Bool {
        return stateClass == UploadingDataState.self
    }
    
    override func didEnter(from previousState: GKState?) {
// Действия которые необходимо сделать когда успешно получили данные
    }
    
    override func willExit(to nextState: GKState) {
// Действия когда закончилось действие этого состояния
    }
}
 
final class FailureState: GKState {
 
    private let viewController: UploadViewController
    
    init(_ viewController: UploadViewController) {
        self.viewController = viewController
    }
 
    override func isValidNextState(_ stateClass: AnyClass) -> Bool {
        return stateClass == UploadingDataState.self
    }
 
    override func didEnter(from previousState: GKState?) {
// Действия которые необходимо сделать когда не удалось получить данные
    }
    
    override func willExit(to nextState: GKState) {
// Действия когда закончилось действие этого состояния
    }
}

В итоге получилась такая простая схема:

Чтобы запустить все эти состояния, достаточно инициализировать GKStateMachine и передать туда все созданные State-классы.

final class UploadViewController: UIViewController {
    
    @IBOutlet private(set) var activityIndicator: UIActivityIndicatorView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let uploadingDataState = UploadingDataState(self)
        let successfulState = SuccessfulState(self)
        let failureState = FailureState(self)
        let stateMachine = GKStateMachine(states:
            [uploadingDataState, successfulState, failureState])
        // Сразу запускаем Uploading State
        stateMachine.enter(UploadingDataState.self)
    }
}

После запуска в UploadingDataState будет вызван метод didEnter(from:), активирован activityIndicator и отправлен запрос на сервер. В зависимости от ответа с сервера будет вызван переход к следующему состоянию, где мы уже сможем реализовать какую-либо другую логику. К примеру, можно будет легко написать реализацию, чтобы из состояния Failure мы вернулись в Uploading и повторили операцию. Также можно создать еще больше состояний, которые могли бы делать другие операции перед успешной загрузкой или после, например закешировать ее в файловой системе или сделать предварительно компрессию, прежде чем отправить на сервер. Таким образом, это может быть альтернативным вариантом, чтобы создать хорошую последовательность действий, которые будут существовать как отдельные классы, и тем самым избежать Callback Hell’a.

Посмотреть пример, предлагаемый Apple по применению GKStateMachine в виде игры, можно в архиве: Dispenser.

Заключение

У самого GameplayKit около десятка инструментов, которые помогают работать со SpriteKit. Но только некоторые из них могут пригодиться при разработке на UIKit. Здесь я рассмотрел те, которые использую чаще всего в разработке неигровых приложений. Конечно, большая часть статьи была посвящена системе рандома, потому что с ней мне чаще всего приходится иметь дело. Но дополнительно это хороший инструмент, который позволяет делать примитивно простую рутину с очень удобочитаемым кодом и минимальным интерфейсом, без использования огромного количества математических формул и магических чисел в своих собственных рандом-методах.

Если же вам интересно, как можно задействовать большую часть инструментов GameplayKit, то рекомендую посмотреть WWDC 2015 session 609, Deeper into GameplayKit with DemoBots.

Исходный код проекта, показанного на WWDC, вы может взять в Documentation Archive или у меня в репозитории, где код полностью сконвертирован до Swift 5.

LinkedIn

1 комментарий

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

Не по сути статьи:
По моему опыту стейт машины очень быстро становятся нечитаемыми. Обычный switch(state_) куда приятнее, и код компактнее. И сам паттерн SM в литературе бывает называют антипаттерном.

Подписаться на комментарии