Il y a 7 mois · 7 minutes · Mobile

iOS : Développer la keynote interactive de Xebicon

Xebicon’16 a été l’occasion pour les équipes mobiles Xebia de relever un challenge : permettre à plus de 700 personnes d’utiliser leurs terminaux mobiles pour interagir en temps réel avec la keynote du train de l’innovation.

Aujourd’hui nous vous décrivons à travers ce tutoriel les dessous de fabrication du module de la keynote pour iOS.

Nous verrons :

  1. La création du module
  2. Son intégration dans une application existante, en l’occurrence Xebicon
  3. Comment communiquer à l’aide de Push Notifications

La keynote

Voici, résumé en quelques points, le déroulé de la keynote :

  1. Les spectateurs s’installent dans la salle et téléchargent l’application Xebicon depuis l’App Store.
  2. Une fois la keynote débutée sur scène, l’application révèle un onglet « keynote ». L’expérience interactive peut commencer !
  3. Les spectateurs peuvent choisir entre deux trains (Bordeaux et Lyon) qui les amèneront à la destination finale : la gare Xebicon. L’application affiche alors un écran de vote.
  4. Une fois les votes clos, les trains partent et divers événements se produisent au cours de la keynote (animal sur les voies, panne…). Tout ceci est retranscrit en temps réel sur le téléphone des spectateurs via des Push Notifications.
  5. Le train arrive à la Xebicon. La keynote est terminée !

Un module intégrable via CocoaPods

La keynote d’ouverture est une nouveauté de Xebicon’16. Elle doit apparaître dans l’application existante disponible sur App Store.

Avant de commencer à coder, il convient de se demander comment faire pour l’intégrer dans l’application. Plusieurs choix s’offrent à nous :

  • Écrire le code directement dans l’application.
  • Développer un module séparé, et l’importer dans l’application.

La solution retenue est la dernière. En effet, la keynote étant totalement indépendante (elle s’affiche dans son propre onglet et n’interagit pas avec le reste de l’application), il est aisé de la développer à part.

Pour l’intégrer, nous avons fait le choix de créer un podspec CocoaPods.

# Notre podspec final
# Il est placé à la racine de notre repository
Pod::Spec.new do |s|
  s.name           = 'KeynoteVote'
  s.version        = '1.1.2'
  s.summary        = 'Vote module for xebicon 2016'
  s.platforms      = { :ios => "9.0" }
  s.source         = { :git => 'git@github.com:xebia-france/xebicon.git' }
  s.source_files   = 'ios-vote/KeynoteVote/**/*.swift'
  s.resources      = ['ios-vote/KeynoteVote/Assets.xcassets', 'ios-vote/KeynoteVote/**/*.strings', 'ios-vote/KeynoteVote/**/*.storyboard']
  s.homepage       = 'https://github.com/xebia-france/xebicon/tree/master/ios-vote/KeynoteVote'
end

Nous n’aurons plus qu’à l’ajouter au Podfile de l’application Xebicon pour l’importer :

source 'xxx' # notre repo privé qui contient le podspec

...
pod 'KeynoteVote'

L’architecture

Modélisation

 La keynote se déroule selon une succession d’évènements : début, ouverture/fermeture des votes, départ des trains, etc. Le nombre d’états possible est figé et connu à l’avance, et la keynote ne peut être que dans un seul à la fois (exemple : impossible d’avoir ouverture et fermeture des votes en même temps). Nous allons donc la modéliser en utilisant un enum.

public enum KeynoteState: String {
  case Welcome = "KEYNOTE_START"
  case End = "KEYNOTE_END"
  case TrainVote = "VOTE_TRAIN_START"
  case TrainVoteEnd = "VOTE_TRAIN_END"
  case TrainDeparture = "TRAIN_DEPARTURE_START"
  case TrainDepartureEnd = "TRAIN_DEPARTURE_END"
  case XebiconArrival = "TRAIN_POSITION"
  case HotDeploymentStart = "HOT_DEPLOYMENT_START"
  case HotDeploymentEnd = "HOT_DEPLOYMENT_END"
  case AvailabilityStart = "AVAILABILITY_START"
  case AvailabilityEnd = "AVAILABILITY_END"
  case Obstacle = "OBSTACLE"
  case ObstacleCleared = "OBSTACLE_CLEARED"
}

Le changement d’état (passer de HotDeploymentStart à HotDeploymentEnd) se fera via un objet tiers, KeynoteEvent, qui contiendra les données associées :

public struct KeynoteEvent {
  public var state: KeynoteState?
  public var message: String?
  public var destination: TrainDestination? = nil // destination du train
  public var obstacle: Obstacle? // obstacle détecté
    
  public init() {}
}

public enum TrainDestination: Int {
  case bordeaux = 1
  case lyon = 2
}

public struct Obstacle {
    let blocked: Bool
    let animalImage: UIImage?

    init(type: String, blocked: Bool) {
        self.blocked = blocked
        self.animalImage = UIImage(named: type, in: Bundle().currentBundle(), compatibleWith: nil)
    }
}

Design

Chaque state de la keynote est représenté par un écran. Reportez-vous au fichier Storyboard.storyboard du code source (ou laissez libre cours à votre imagination !)

Par la suite nous aurons besoin de référencer les ViewController de notre storyboard. Plutôt que d’écrire du code redondant, nous conseillons d’utiliser SwiftGen qui créera deux classes : Storyboard et StoryboardScene.

Routing

Nous avons notre KeynoteState et les écrans associés. Il nous faut maintenant pouvoir dérouler les écrans.   

Pour ce faire, nous avons créé un routeur minimaliste qui affiche l’écran du dernier state connu.

protocol Route {
  var viewController: UIViewController { get }
}

class Router<T: Route> {
  let navigationController: UINavigationController
  var didNavigate: (UIViewController) -> Void = { _ in }
  
  init(navigationController: UINavigationController) {
    navigationController = navigationController
  }
  func navigate(_ route: T) {
    DispatchQueue.main.async {
      let controller = route.viewController
      navigationController.setViewControllers([controller], animated: false)
      didNavigate(controller)
    }
  }
}

Nous ferons la navigation en fonction d’un state (KeynoteState). Il va donc nous falloir étendre KeynoteState pour y implémenter Route.

// Note : StoryboarScene et Storyboard sont les classes générées par SwiftGen pour notre Storyboard.storyboard
extension KeynoteState: Route {
  var viewController: UIViewController {
    switch(self) {
    case .Welcome, .End:
      return StoryboardScene.Storyboard.instantiateKeynoteWelcome()
    case .TrainVote:
      return StoryboardScene.Storyboard.instantiateTrainVote()
    case .TrainVoteEnd:
      return StoryboardScene.Storyboard.instantiateTrainVoteComplete()
    case .TrainDeparture:
      return StoryboardScene.Storyboard.instantiateTrainRideStart()
    case .TrainDepartureEnd:
      return StoryboardScene.Storyboard.instantiateTrainRideEnd()
    case .AvailabilityStart:
      return StoryboardScene.Storyboard.instantiatePurchaseStart()
    case .AvailabilityEnd:
      return StoryboardScene.Storyboard.instantiatePurchaseEnd()
    case .HotDeploymentStart:
      return StoryboardScene.Storyboard.instantiateDeploymentStart()
    case .HotDeploymentEnd:
      return StoryboardScene.Storyboard.instantiateDeploymentEnd()
    case .Obstacle:
      return StoryboardScene.Storyboard.instantiateObstacle()
    case .ObstacleCleared:
      return StoryboardScene.Storyboard.instantiateObstacleCleared()
    case .XebiconArrival:
      return StoryboardScene.Storyboard.instantiateKeynoteEnd()
    }
  }
}

Interactions

Le module de vote possède deux interactions avec l’utilisateur : voter pour un train, et acheter des boissons au wagon-bar. Les deux sont sensiblement similaires, nous nous intéresserons donc uniquement au premier.

Dans le ViewController, nous allons donc ajouter une action pour gérer le train sélectionné :

protocol TrainVoteViewDelegate {
 func didVote(_ vote: TrainDestination)
}

class TrainVoteViewController : UIViewController, TrainVoteViewDelegate {
 func viewDidLoad() {
  super.viewDidLoad()
  guard let view = self.view as? TrainView else { fatalError("Wrong view type") }
  view.delegate = self
 }

 func didVote(_ train: TrainDestination) {
  // Envoyer le vote au serveur
   }
}

extension TrainVoteView {
 @IBAction func bdxVoteSelected() {
  voteButton.isHidden = true
  delegate?.didVote(.bordeaux)
 }

 @IBAction func lyonVoteSelected() {
  voteButton.isHidden = true
  delegate?.didVote(.lyon)
 }
}

API publique

Nous ajoutons une classe qui fera office de façade. Ce sera notre interface publique depuis l’extérieur pour intéragir avec notre module de vote. Elle nous permettra :

  • d’initialiser le composant
  • de changer d’état via des KeynoteEvent
public protocol KeynoteVoteCenterDelegate {
    func willStart(keynote: KeynoteVoteCenter)
    func didReceive(state: KeynoteState, keynote: KeynoteVoteCenter)
}

open class KeynoteVoteCenter : NSObject {
    fileprivate var started = false
    fileprivate let router: Router<KeynoteState>
    public var delegate: KeynoteVoteCenterDelegate?

    open var viewController: UIViewController {
        return router.navigationController
    }

    override public init() {
        self.router = Router(navigationController: StoryboardScene.Storyboard.initialViewController())
        super.init()
    }

    open func requestStateChange(_ payload: KeynoteEvent) -> Bool {
        guard let state = payload.state, started else {
            return false
        }
        navigate(state)
        return true
    }

    public func start() {
        if !started {
            delegate?.willStart(keynote: self)
        }
        started = true
    }

 func navigate(_ state:KeynoteState) {
        delegate?.didReceive(state: state, keynote: self)
        router.navigate(state)
    }
}

Integration

Maintenant que notre module est prêt, nous pouvons l’intégrer dans notre application, en commençant par l’importer via CocoaPods.

Ensuite, dans l’AppDelegate, nous instancions le module et l’attachons à l’application.

// Application conteneur
class AppDelegate : UIApplicationDelegate {
 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
  setupKeynote()
  return true
 }

 func setupKeynote() {
  voteCenter = KeynoteVoteCenter()
 }

 func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
  voteCenter.start()
 }

 func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping  (UIBackgroundFetchResult) -> Void) {
  var keynoteEvent = KeynoteEvent()
        
        try? keynoteEvent.map(userInfo)
        voteCenter.requestStateChange(keynoteEvent)
 }
}

Notre KeynoteVoteCenterDelegate nous permettra d’afficher le module keynote dans la tabbar de notre application, et uniquement lorsque celle-ci est en cours :

extension MainViewController : KeynoteVoteCenterDelegate {
 public func willStart(keynote: KeynoteVoteCenter) {
    }

    public func didReceive(state: KeynoteState, keynote: KeynoteVoteCenter) {
        DispatchQueue.main.async {
            switch(state) {
            case .End:
                self.removeKeynote(keynote.viewController)
            case .Welcome:
                self.addKeynote(keynote.viewController)
                self.selectedViewController = keynote.viewController
            default:
                self.addKeynote(keynote.viewController)
                
            }
        }
    }

 func removeKeynote(_ controller: UIViewController) {
        let controllers = viewControllers ?? []
        setViewControllers(controllers.filter { $0 != controller }, animated: true)
    }

 func addKeynote(_ controller: UIViewController) {
        var controllers = viewControllers ?? []
        let hasKeynote = (controllers.filter { $0 == controller }.count > 0)

        if !hasKeynote {
            controllers.append(controller)
            setViewControllers(controllers, animated: true)
            let tabBarImage = UIImage(named: "tabBarRail")
            controller.tabBarItem = UITabBarItem(title: "Keynote", image: tabBarImage, tag: 0)
            controller.tabBarItem.image = tabBarImage?.withRenderingMode(.alwaysTemplate)
            controller.tabBarItem.selectedImage = UIImage(named: "tabBarRailSelected")
        }
    }
}

// À ajouter dans setupKeynote de AppDelegate
func setupKeynote() {
  voteCenter.delegate = window.rootViewController as? KeynoteVoteCenterDelegate
}

Nous pouvons maintenant lancer notre application. On n’aura plus qu’à envoyer des Push Notifications pour faire apparaître la keynote !

Jean-Christophe Pastant
Consultant iOS, fervent défenseur du code de qualité.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *