Monsieur Météo en Swift

Dans ce tutoriel, nous allons nous amuser à écrire vite fait une mini application météo !

Une simple application qui dit, pour ceux qui n'ont pas de fenêtres dans leur pièce, la température qu'il fait dehors, le vent et sa direction, s'il y a des nuages, s'il pleut, etc. ;)

Pour rester dans le domaine du faisable dans le cadre de ce tutoriel, notre app va simplement écrire le résultat par texte, on ne fera pas d'interface graphique.

Pas de géolocalisation non plus, on tape soi-même le nom de la ville - toutes ces fantaisies seront pour un autre article, il y a déjà beaucoup à faire avant d'en arriver là.

Intro

Nous utilisons Swift comme langage, mais il n'y a pas grand chose de spécifique aux plateformes d'Apple dans cet article puisque nous ne faisons pas d'interface graphique.

Je ne sais pas à quel niveau de lecteur je m'adresse, avec ce tutoriel, mais je crois qu'il y en a autant pour le simple curieux que pour l'amateur éclairé.

On va faire attention à la sémantique : non seulement trouver des noms qui ont du sens, mais aussi au besoin créer des alias et des wrappers pour rendre le tout plus parlant pour les utilisateurs du code.

On va aussi faire attention au contrôle d'accès : déclarer publiques ou privées les méthodes et propriétés de manière à respecter la séparation des responsabilités.

On essaiera de penser à la possible future ouverture de notre application vers d'autres horizons, et donc de garder le code bien DRY et bien SOLID.

Teaser

Au final, on pourra obtenir un résultat de ce type :

Paris (FR), 2016/06/07 19:24:59. Temp: 26 ºC. Ciel: partiellement ensoleillé. Vent: ONO à 4.6 km/h.

Echauffement

Cette application est centrée sur des appels à une API en REST fournie par Open Weather Map.

Sans accès à cette API, notre application n'a pas d'utilité… Pour accéder aux endpoints de cette API nous avons besoin d'un identifiant (nommé chez OWM une “APPID”) que l'on devra se fournir sur le site. C'est gratuit et ça prend à peine une minute : vous vous inscrivez, vous créez l'identifiant que vous notez, et c'est fini.

Cette API va nous fournir sa réponse au format JSON.

Décoder proprement du JSON avec Swift peut s'avérer non pas complexe mais rébarbatif et embrouille facilement les débutants, et JSON n'est pas le sujet principal de ce tuto, donc pour s'éviter des tourments inutiles nous utiliserons SwiftyJSON, une bibliothèque réputée et facile à utiliser. Suivez les instructions sur le site pour l'installer dans votre projet (voir prochain chapitre).

Autre chose : le public de ce tutoriel est francophone, donc les sorties sont en français (ce qui est affiché par l'application). En revanche, quel que soit le public, le code lui-même est en anglais : les variables ont des noms anglais, les verbes sont en anglais, etc. C'est tout simplement la convention générale : le code doit être universel, alors que les sorties doivent être localisées.

Allez, c'est parti !

Par quoi on commence ?

Bon voilà, on veut choper le temps qu'il fait dehors.

Il faut donc télécharger les infos d'un serveur et les interpréter.

Mais il nous faut d'abord créer un projet.

Nouveau projet

Dans Xcode, faites menu “New”, allez dans “OS X”, sous-rubrique “Application” puis choisissez l'icone “Command Line Tool”.

Faites “Next” puis entrez les infos, choisissez “Swift” puis un endroit ou sauvegarder et c'est prêt.

Sélectionnez main.swift dans le panneau de navigation, faites CMD+R pour compiler et exécuter le projet pour vérifier que tout fonctionne. Vous pouvez maintenant supprimer la ligne test “Hello World”.

CLI

On s'occupera en toute fin de tutoriel d'ajouter la partie interface texte à notre app, c'est-à-dire lui permettre de s'exécuter dans le Terminal en acceptant des paramètres (ville et pays) en entrée.

Pendant le développement, on fera tout à partir du code lui-même, dans notre projet (et optionnellement dans un Playground pour faire des tests, ce qui est une bonne habitude à prendre en général).

SwiftyJSON

Téléchargez le zip, ouvrez-le puis attrapez le fichier “SwiftyJSON.swift” qui est dans le dossier “Source” et lâchez-le sur Xcode, à côté des autres fichiers.

Faites bien attention que “Copy items if needed” soit coché dans le panneau qui s'affiche, et que “Create folder references” et la ligne en face de “Add to targets” soient également cochés.

Allez, maintenant, écrivons le code pour le téléchargement !

Téléchargement

Faisons-nous une classe “globale” nommée “Meteo”.

Allez dans menu “New” puis “OS X” puis “Source” (faites donc CMD+N à la place, tiens) et sélectionnez l'icone “Swift File” et faites “Next”. Sauvegardez-le à la racine du projet, à côté de “main.swift”. Nommez ce fichier “Meteo.swift” (avec la majuscule).

Créons la classe et donnons-lui une méthode privée pour télécharger d'un serveur :

public class Meteo {
    private func getResponse(url: NSURL, completion: (data: NSData)->()) {
        NSURLSession.sharedSession().dataTaskWithURL(url) { (
                data: NSData?, response: NSURLResponse?, error: NSError?
            ) -> Void in
            if let data = data where error == nil {
                completion(data: data)
            } else {
                print("oops, ça répond pas")
            }
        }.resume()
    }    
}

Si vous n'êtes pas familier avec ça, c'est un peu poilu, mais voilà comment ça marche : la signature de la méthode prend un paramètre, url, et comprend un callback, ici nommé completion.

private func getResponse(url: NSURL, completion: (data: NSData)->())

Dans la signature, le callback lui-même est (data: NSData)->() et se nomme completion (juste parce que c'est plus parlant que “pizza”, mais on donne le nom qu'on veut en fait).

On démarre la session d'accès au réseau en lui passant l'URL, et on recevra trois variables qui seront des Optionals, data, response et error :

NSURLSession.sharedSession().dataTaskWithURL(url) { (
        data: NSData?, response: NSURLResponse?, error: NSError?
    ) -> Void in

On vérifie que data existe et qu'il n'y ait pas d'erreur signalée :

if let data = data where error == nil

Puis on passe data au callback.

completion(data: data)

De l'autre côté, on utilisera ça comme ça, avec une “trailing closure” :

getResponse(url...) { data in
    // ici `data` sera disponible dès que... les data seront disponibles
}

Les callback, c'est chaud à comprendre au début, mais c'est indispensable pour faire du réseau, puisqu'on ne sait pas quand on va recevoir les data du serveur - si on les reçoit jamais. On ne peut pas attendre, donc on passe par ce système qui revient à dire “moi, application, je lance cette requête sur le réseau, préviens-moi dès que tu reçois le résultat, cher processeur, et je reviendrai vers toi, pendant ce temps je vais bosser sur autre chose”.

Bon, mais juste choper les data ça suffit pas, on les veut en JSON, et on veut tout ça dans un format correct, dans un beau paquet.

On va se créer une struct pour contenir le résultat. Faites un nouveau fichier “NetworkResult.swift” avec ce contenu :

public struct NetworkResult {
    let success: Bool
    let json: JSON!  // "JSON" est le type des objets créés par SwiftyJSON
    let error: NSError?
}

et modifiez notre méthode getResponse dans Meteo.swift pour que le callback utilise cette struct au lieu d'utiliser les data directement :

public class Meteo {
    private func getResponse(url: NSURL, completion: (networkResult: NetworkResult)->()) {
        NSURLSession.sharedSession().dataTaskWithURL(url) { (
            data: NSData?, response: NSURLResponse?, error: NSError?
            ) -> Void in
            if let data = data where error == nil {
                let jsonObject = JSON(data: data)
                completion(networkResult:
                    NetworkResult(success: true, json: jsonObject, error: nil))
            } else {
                completion(networkResult:
                    NetworkResult(success: false, json: nil, error: error))
            }
            }.resume()
    }
}

On a donc maintenant un objet NetworkResult qui contient l'état (succès ou échec) dans un booléen, le contenu (les data binaires transformées en JSON) optionnel dans un objet SwiftyJSON et une erreur optionnelle dans un classique NSError.

Notez que le JSON et l'erreur sont déclarés comme implicitly unwrapped optionals et non pas comme optionnels normaux : on a besoin que ce soit optionnel, vu qu'il peut y en avoir ou pas, mais quand il y en a on ne veut plus le traiter comme optionnel.

Comme il nous faut un token pour cette API, on va le stocker dans Meteo au moment de l'initialisation.

public class Meteo {
    private let appID: String

    public init(appID: String) {
        self.appID = appID
    }   

Il ne reste plus qu'à créer les URL en fonction de si l'on fournit le pays ou pas.

On a besoin d'une fonction pour encoder les URL (s'il y a des accents dans le nom de la ville ou du pays).

Faites un nouveau fichier “Extensions.swift” puis ajoutez ça :

public extension String {
    func percentEncoded() -> String? {
        return self.stringByAddingPercentEncodingWithAllowedCharacters(
            NSCharacterSet.URLQueryAllowedCharacterSet()
        )
    }
}

De retour dans la classe Meteo, la méthode “make URL” elle-même :

private func makeURL(city: String, country code: String? = nil) -> NSURL? {
    guard let city = city.percentEncoded() else {
        return nil
    }
    if let c = code, cc = c.percentEncoded() {
        return NSURL(string: "http://api.openweathermap.org/data/2.5/weather?q=\(city),\(cc)&appid=\(appID)&units=metric&lang=fr")
    }
    return NSURL(string: "http://api.openweathermap.org/data/2.5/weather?q=\(city)&appid=\(appID)&units=metric&lang=fr")
}

On s'assure d'abord de pouvoir encoder le nom de la ville, puisque c'est un paramètre obligatoire, puis on crée l'URL en fonction de la présence - et de la possibilité d'encoder - le nom du pays.

JSON

Bon, on a le JSON, encapsulé dans un objet. Maintenant on veut récupérer les infos qu'il y a dedans.

Première chose, on inspecte l'API pour voir comment elle répond.

Si on lui demande “Paris” et “FR” voici sa réponse en JSON :

{
  "coord": {
    "lon": 145.77,
    "lat": -16.92
  },
  "weather": [
    {
      "id": 803,
      "main": "Clouds",
      "description": "broken clouds",
      "icon": "04n"
    }
  ],
  "base": "cmc stations",
  "main": {
    "temp": 293.25,
    "pressure": 1019,
    "humidity": 83,
    "temp_min": 289.82,
    "temp_max": 295.37
  },
  "wind": {
    "speed": 5.1,
    "deg": 150
  },
  "clouds": {
    "all": 75
  },
  "rain": {
    "3h": 3
  },
  "dt": 1435658272,
  "sys": {
    "type": 1,
    "id": 8166,
    "message": 0.0166,
    "country": "AU",
    "sunrise": 1435610796,
    "sunset": 1435650870
  },
  "id": 2172797,
  "name": "Cairns",
  "cod": 200
}

C'est donc un dictionnaire qui contient des dictionnaires et des arrays, rien que du classique.

Si on veut la température, qui est un Int qui est dans “temp” qui est dans “main”, on fait avec SwiftyJSON :

let temp = json["main"]["temp"].int

Si on veut la description, qui est une String dans “description” dans le premier objet dans “weather” :

let desc = json["weather"][0]["description"].string

Nous allons donc créer une méthode à notre classe Meteo qui va décoder tout ce que l'on veut prendre dans ce JSON, et le retourner sous forme d'objet nommé CurrentWeather.

Créez un nouveau fichier “CurrentWeather.swift” et ajoutez ça :

public struct CurrentWeather {
    typealias MeterSecond = Double
    typealias KMHour = Double
    typealias MeteorologicalDegree = Int

    let date: NSDate
    let city: String
    let country: String
    let celsius: Int
    let category: String
    let subCategory: String
    let windSpeed: MeterSecond
    let windDirection: MeteorologicalDegree?
    let iconURL: NSURL
}

Je vous laisse observer la structure de notre struct CurrentWeather, rien de compliqué : on contient les propriétés que l'on extrait du JSON, et l'on a quelques typealias simplement pour l'expressivité du code.

De retour dans la classe Meteo, on ajoute cette méthode qui décode le contenu et initalise le modèle (la struct) à partir de ce contenu, ce qui nous donne au complet :

public class Meteo {

    private func makeCurrentWeather(json: JSON) -> CurrentWeather? {
        guard let temp = json["main"]["temp"].int,
            speed = json["wind"]["speed"].double,
            cat = json["weather"][0]["main"].string,
            icon = json["weather"][0]["icon"].string,
            iconURL = NSURL(string: "http://openweathermap.org/img/w/\(icon).png"),
            desc = json["weather"][0]["description"].string,
            city = json["name"].string,
            country = json["sys"]["country"].string else {
                return nil
        }
        return CurrentWeather(date: NSDate(),
                              city: city,
                              country: country,
                              celsius: temp,
                              category: cat,
                              subCategory: desc,
                              windSpeed: speed,
                              windDirection: json["wind"]["deg"].int,
                              iconURL: iconURL)
    }

    private func getResponse(url: NSURL, completion: (networkResult: NetworkResult)->()) {
        NSURLSession.sharedSession().dataTaskWithURL(url) { (
            data: NSData?, response: NSURLResponse?, error: NSError?
            ) -> Void in
            if let data = data where error == nil {
                let jsonObject = JSON(data: data)
                completion(networkResult:
                    NetworkResult(success: true, json: jsonObject, error: nil))
            } else {
                completion(networkResult:
                    NetworkResult(success: false, json: nil, error: error))
            }
            }.resume()
    }
}

Cette méthode makeCurrentWeather sait qu'elle reçoit du JSON, mais on ne sait pas si ce JSON sera valide ou complet (le serveur pourrait déconner) donc on retourne un Optional du modèle CurrentWeather.

Classe Meteo, suite et fin

Finissons notre class Meteo.

Je veux que l'on garde la trace de tous les résultats fournis par l'API, un historique des appels ayant retourné un résultat.

Je veux aussi qu'il y ait une seule méthode publique dans cette classe Meteo, currentWeather, et une seule propriété publique, history, et que tout le reste de l'implémentation soit privé.

Créez un nouveau fichier “WeatherResult.swift” et collez ça :

public struct WeatherResult {
    let success: Bool
    let weather: CurrentWeather!
    let error: NSError?
}

Voilà la classe Meteo au complet :

public class Meteo {

    private let appID: String
    public var history: [WeatherResult] = []

    public init(appID: String) {
        self.appID = appID
    }

    public func currentWeather(city: String,
                        country code: String? = nil,
                        completion: (weatherResult: WeatherResult)->()) {
        guard let url = makeURL(city, country: code) else {
            completion(weatherResult:
                WeatherResult(success: false, weather: nil, error: nil)
            ); return
        }
        getResponse(url) { (networkResult) in
            if let current = self.makeCurrentWeather(networkResult.json)
            where networkResult.success {
                let wr = WeatherResult(success: true,
                                       weather: current,
                                       error: nil)
                self.history.append(wr)
                completion(weatherResult: wr)
            } else {
                let wr = WeatherResult(success: false,
                                       weather: nil,
                                       error: networkResult.error)
                completion(weatherResult: wr)
            }
        }
    }

    private func getResponse(url: NSURL,
                             completion: (networkResult: NetworkResult)->()) {
        NSURLSession.sharedSession().dataTaskWithURL(url) { (
                data: NSData?, response: NSURLResponse?, error: NSError?
            ) -> Void in
            if let data = data where error == nil {
                completion(networkResult:
                    NetworkResult(
                        success: true,
                        json: JSON(data: data),
                        error: nil))
            } else {
                completion(networkResult:
                    NetworkResult(
                        success: false,
                        json: nil,
                        error: error))
            }
        }.resume()
    }

    private func makeCurrentWeather(json: JSON) -> CurrentWeather? {
        guard let temp = json["main"]["temp"].int,
            speed = json["wind"]["speed"].double,
            cat = json["weather"][0]["main"].string,
            icon = json["weather"][0]["icon"].string,
            iconURL = NSURL(string: "http://openweathermap.org/img/w/\(icon).png"),
            desc = json["weather"][0]["description"].string,
            city = json["name"].string,
            country = json["sys"]["country"].string else {
                return nil
        }
        return CurrentWeather(date: NSDate(),
                              city: city,
                              country: country,
                              celsius: temp,
                              category: cat,
                              subCategory: desc,
                              windSpeed: speed,
                              windDirection: json["wind"]["deg"].int,
                              iconURL: iconURL)
    }

    private func makeURL(city: String, country code: String? = nil) -> NSURL? {
        guard let city = city.percentEncoded() else {
            return nil
        }
        if let c = code, country = c.percentEncoded() {
            return NSURL(string: "http://api.openweathermap.org/data/2.5/weather?q=\(city),\(country)&appid=\(appID)&units=metric&lang=fr")
        }
        return NSURL(string: "http://api.openweathermap.org/data/2.5/weather?q=\(city)&appid=\(appID)&units=metric&lang=fr")
    }

}

Le test

Il est grand temps d'exécuter notre code.

CLI

Une particularité dûe à la nature CLI de notre app c'est que pouvoir télécharger de manière asynchrone comme on fait (en background, avec un callback) necessite d'ajouter ceci :

NSRunLoop.currentRunLoop().run()

à la fin du fichier “main.swift”, et de gérer nous-même les sorties de programme avec exit.

Go !

On instancie notre classe Meteo avec notre APPID puis on utilise notre méthode currentWeather :

let meteo = Meteo(appID: "d21991d7851f849bfe8cc24d12c795d0")

meteo.currentWeather("paris", country: "fr") { (weatherResult) in
    if weatherResult.success {
        print(weatherResult.weather.celsius)
        print(meteo.history[0].weather.celsius)
        exit(0)
    } else {
        if let error = weatherResult.error {
            print(error)
        } else {
            print("oops")
        }
        exit(1)
    }
}

NSRunLoop.currentRunLoop().run()

Dans la console de Xcode alors est censée s'afficher la température du moment, suivie de la même (mais piochée dans l'historique), suivie de “Program ended with exit code: 0” qui signifie “tout s'est bien déroulé”. On peut passer à la suite !

Interlude

M/S et KM/H

L'API ne nous donne pas toujours la vitesse du vent, et quand elle la donne, c'est en mètres par seconde.

En ce qui me concerne les “mètres par seconde” ça me parle moins que les “kilomètres par heure”, donc on va ajouter une computed property à notre struct pour faire la conversion, puis arrondir le tout à une décimale pour des raisons de lisibilité.

Ajoutez ça dans le fichier “Extensions.swift” :

public extension Double {
    func roundedOneDecimal() -> Double {
        return round(self * 10.0) / 10.0
    }
}

Retournez dans CurrentWeather.swift et ajoutez la computed property windSpeedKMH :

public struct CurrentWeather {
    typealias MeterSecond = Double
    typealias KMHour = Double
    typealias MeteorologicalDegree = Int

    let date: NSDate
    let city: String
    let country: String
    let celsius: Int
    let category: String
    let subCategory: String
    let windSpeed: MeterSecond
    let windDirection: MeteorologicalDegree?
    let iconURL: NSURL

    var windSpeedKMH: KMHour {
        let s = windSpeed * 3.6
        return s.roundedOneDecimal()
    }
}

Convertir les degrés d'angle

L'API fournit la direction du vent en degrés d'angle, mesure météorologique.

Ca signifie que sur un cercle (donc 360 degrés), le nombre fourni indiquera l'orientation cardinale : 1 sera le nord, jusqu'à 89 on va vers 90 qui sera l'est, 180 le sud, 270 l'ouest et finalement 360, retour au nord.

Et nous voulons obtenir à la place “N” pour Nord, “NO” pour Nord-Ouest, “ESE” pour Est-Sud-Est, “SSE” pour Sud-Sud-Est, etc.

On va donc diviser notre cercle en 16 parties, puisque nous voulons 16 valeurs à partir de 360 degrés, et ces 16 parties seront nos points cardinaux :

"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSO", "SO", "OSO", "O", "ONO", "NO", "NNO"

Si l'on garde ces valeurs dans cet ordre, dans un array, on peut utiliser cette formule pour trouver l'index à partir des degrés :

((degré / 22.5) + 0.5) mod 16

Notre cercle de 360 est divisé en 16 parts, ce qui donne 22.5, puis nous faisons mod 16 (on divise par 16 et on garde le reste pour retrouver le mouvement circulaire).

En Swift :

let deg = // un nombre de 0 à 359
let compass = ["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSO","SO","OSO","O","ONO","NO","NNO"]
let index = Int((Double(deg) / 22.5) + 0.5) % 16
let result = compass[index]

Note: en Swift, nous sommes obligés d'opérer sur des nombres de type Double puisque nos valeurs ne sont pas des entiers, mais au final l'index doit tout de même être explicitement de type Int, d'où ces conversions de type.

Et pour rendre ça simple d'usage dans le code je propose de l'intégrer dans une extension à Int :

public extension Int {
    func degreesToCompass() -> String {
        let compass = ["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSO","SO","OSO","O","ONO","NO","NNO"]
        let index = Int((Double(self) / 22.5) + 0.5) % 16
        return compass[index]
    }    
}

Ce qui permettra, si par exemple l'API fournit 42 :

let direction = 42.degreesToCompass() // "NE"

Affichage

On pourrait se contenter de récupérer les infos dans la struct et de faire print, mais nous allons nous amuser à développer un système plus complet et plus ouvert pour cette tâche.

Par exemple, on veut que l'utilisateur puisse choisir entre plusieurs styles de description, avec ou sans le vent. On veut aussi afficher la date au format localisé, inclure les données dans des phrases, pouvoir faire des conversions, etc.

Une bonne solution serait de créer une classe “Printer” et de la fournir en tant que “delegate” à notre classe Meteo (comme expliqué dans notre tutoriel précédent). Pour changer, cette fois nous allons créer un Monsieur Météo. ;)

Option de style

Disons que l'on veut retourner une version courte, une version normale et une version longue de notre résultat.

Commencons par créer une énumération pour ces styles. Faites un nouveau fichier “WeatherDescriptionStyle.swift” et ajoutez-y cet enum :

public enum WeatherDescriptionStyle {
    case MiniString, String, DetailedString
}

Une classe “descripteur”

Créons une classe qui servira à contenir la mécanique necessaire au formatage des informations en fonction du style choisi.

Faites un nouveau fichier “WeatherDescriptor.swift” dans lequel vous ajoutez un formatteur de date :

public class WeatherDescriptor {

    private let dateFormatter: NSDateFormatter

    public init() {
        dateFormatter = NSDateFormatter()
        dateFormatter.calendar = NSCalendar.currentCalendar()
        dateFormatter.locale = NSLocale.currentLocale()
        dateFormatter.dateFormat = "yyy/MM/dd HH:mm:ss"
    }

    private func dateString(weather: CurrentWeather) -> String {
        return dateFormatter.stringFromDate(weather.date)
    }    
}

Maintenant, le formatage lui-même.

On veut une méthode qui accepte un objet CurrentWeather et un style WeatherDescriptionStyle, et qui retourne, en fonction du style, une String différente :

    private func makeDescription(weather: CurrentWeather, 
                                 style: WeatherDescriptionStyle) -> String {
        let loc = "\(weather.city) (\(weather.country))"
        let ds = dateString(weather)
        let base = "\(loc), \(ds)."
        let temp = "Temp: \(weather.celsius) ºC."
        let normal = "\(base) \(temp)"
        let mood = "Ciel: \(weather.subCategory)."
        switch style {
        case .DetailedString:
            if let dir = weather.windDirection {
                return "\(normal) \(mood) Vent: \(dir.degreesToCompass()) à \(weather.windSpeed) km/h."
            } else {
                return "\(normal) \(mood)"
            }
        case .String:
            return normal
        case .MiniString:
            return temp
        }
    }

On crée des variables temporaires pour se faciliter la tâche puis avec un switch on retourne une différente composition en fonction du style choisi.

Voici le descripteur au complet :

public class WeatherDescriptor {

    private let dateFormatter: NSDateFormatter

    public init() {
        dateFormatter = NSDateFormatter()
        dateFormatter.calendar = NSCalendar.currentCalendar()
        dateFormatter.locale = NSLocale.currentLocale()
        dateFormatter.dateFormat = "yyy/MM/dd HH:mm:ss"
    }

    public func describe(weather: CurrentWeather,
                         style: WeatherDescriptionStyle = .DetailedString) {
        let d = makeDescription(weather, style: style)
        print(d)
    }

    private func makeDescription(weather: CurrentWeather,
                                 style: WeatherDescriptionStyle) -> String {
        let loc = "\(weather.city) (\(weather.country))"
        let ds = dateString(weather)
        let base = "\(loc), \(ds)."
        let temp = "Temp: \(weather.celsius) ºC."
        let normal = "\(base) \(temp)"
        let mood = "Ciel: \(weather.subCategory)."
        switch style {
        case .DetailedString:
            if let dir = weather.windDirection {
                return "\(normal) \(mood) Vent: \(dir.degreesToCompass()) à \(weather.windSpeed) km/h."
            } else {
                return "\(normal) \(mood)"
            }
        case .String:
            return normal
        case .MiniString:
            return temp
        }
    }

    private func dateString(weather: CurrentWeather) -> String {
        return dateFormatter.stringFromDate(weather.date)
    }    
}

Monsieur Météo

Nous voici enfin en possession de tous les éléments necessaires à la création de notre Monsieur Météo, extensible et ouvert aux manipulations et extensions futures.

Nous pouvons consulter l'API, récupérer et décoder les résultats, en faire des objets que nous stockons, et nous avons un afficheur qui nous permet d'exploiter ces objets.

Notre Monsieur Météo sera donc un “convenience wrapper” - une classe dont la fonction est de faciliter l'usage d'autres classes - pour nos objets.

Faites un nouveau fichier “WeatherMan.swift”, et ajoutez-y une classe contenant les éléments de base :

public class WeatherMan {

    private let meteo: Meteo
    private let descriptor: WeatherDescriptor

    // on peut initaliser l'objet avec une AppID ou utiliser celle par défaut
    public init(id: String = "d21991d7851f849bfe8cc24d12c795d0") {
        meteo = Meteo(appID: id)
        descriptor = WeatherDescriptor()
    }
}

Nous y ajoutons finalement une méthode unique pour faire fonctionner le tout :

    public func printCurrentWeather(city: String,
                             country code: String? = nil,
                             style: WeatherDescriptionStyle = .DetailedString) {
        meteo.currentWeather(city, country: code) { (result) in
            if result.success {
                self.descriptor.describe(result.weather, style: style)
            } else if let error = result.error {
                print(error)
            } else {
                print("Unknown error")
            }
        }
    }

Et nous voici donc enfin capables d'utiliser notre Monsieur Météo ! Avec par exemple, dans “main.swift” :

let wm = WeatherMan()
wm.printCurrentWeather("paris", country: "france")

ou

let wm = WeatherMan()
wm.printCurrentWeather("new-york", style: .String)

ou encore

let wm = WeatherMan(id: "d21991d7851f849bfe8cc24d12c795d0")
wm.printCurrentWeather("acapulco", country: "mx", style: .MiniString)

Etc.

CLI

Il nous faut maintenant intégrer cette classe WeatherMan dans une véritable application, qui s'exécute sur la machine de l'utilisateur.

Ca pourrait être une web app, une app fenêtrée OS X, une app iOS - pour des raisons de temps, comme dit précédemment, nous allons faire une app de type Command Line Interface.

Créer l'exécutable

Pendant les tests, c'est simple : dans Xcode, faites CMD+B pour compiler le projet, et l'exécutable est disponible dans le dossier Products.

Vous pouvez alors copier ce fichier où vous voulez et lancer l'application à partir du Terminal.

Par exemple, vous copiez le fichier dans “scripts” à la racine de votre disque, vous le renommez “weatherman”, puis dans le Terminal vous faites :

cd /scripts
./weatherman params

pour lancer l'app “weatherman” à partir du dossier “scripts” (on ajoute ./ devant pour spécifier que l'on lance ce fichier et pas un autre nommé identiquement et qui serait dans les dossiers système).

On verra à la fin comment exporter réellement l'application terminée et la placer, justement, dans un dossier reconnu par le système.

Paramètres pour notre app

En Swift, grâce à Foundation, une application CLI peut recevoir les arguments passés par l'utilisateur à l'aide de NSProcessInfo.processInfo().arguments.

C'est-à-dire que si l'on lance l'application à partir du Terminal en lui passant l'argument “test” comme ceci :

weatherman test

avec ceci dans notre main.swift :

let input = NSProcessInfo.processInfo().arguments
print(input)

on va recevoir ceci :

["/scripts/weatherman", "test"]

Nous, ce qu'on veut, c'est recevoir un nom de ville, et, optionnellement, un nom de pays.

Essayons :

weatherman paris france

On reçoit :

["/scripts/weatherman", "paris", "france"]

Bien, on peut en déduire que l'on aura pas besoin du premier élément de l'array, que le deuxième sera le nom de la ville, et que le dernier, s'il existe, sera le nom du pays.

Problème : et si l'utilisateur inversait la ville et le pays ? Ah là là… Nous allons donc ajouter des labels à nos paramètres. il faudra entrer --town ou -t avant le nom de la ville, et --country ou -c avant le nom du pays :

weatherman -t paris -c france

ou juste

weatherman -t paris

Après tout, c'est logique d'avoir un label, on pourra comme ça également ajouter une aide :

weatherman -h

ou

weatherman --help

Bon, on se rend compte qu'on va recevoir un array avec des Strings comme ça, sans moyen de séparer les commandes des paramètres :

["/scripts/weatherman", "-t", "paris", "-c", "france"]

ou

["/scripts/weatherman", "-c", "france", "-t", "paris"]

Oh, et puis il faut aussi en fait ajouter un paramètre optionnel pour le style…

Pas bien grave : on va créer une méthode pour trier tout ce qui nous arrive, renvoyer un message d'erreur si ça ne correspond pas, etc.

Le principe : on crée une version de l'array sans le premier objet, puis on regarde si l'on trouve “-c” ou “-t” ou “-h”. Si l'on trouve “-h” on affiche l'aide, si l'on trouve “-t” ou “-c” on récupère l'objet suivant, puis on inspecte le reste de l'array de la même façon.

C'est un peu laborieux, même pour seulement quelques options comme nous, c'est pourquoi si l'on voulait pousser l'interaction il vaudrait mieux utiliser un framework dédié.

Créons tout d'abord un fichier “CLIArguments.swift” qui contiendra une struct pour encapsuler le résultat :

public struct CLIArguments {
    var town: String = ""
    var country: String?
    var style: WeatherDescriptionStyle!
}

Et voici un exemple de classe pour gérer l'input de notre CLI :

public class CLI {

    let input: [String]
    var arguments = CLIArguments()
    let errorMessage = "Something went wrong. Please read the Help:\nweatherman -h"
    let helpMessage = "This is a simple WeatherMan application.\nNeeds `-t` for the town, and optionnally `-c` for a country and `-s` for a style (short, normal, long).\nExample:\nweatherman -t paris -c france -s short"

    init(input: [String]) {
        self.input = input
        makeArgs()
    }

    public func getArgs() -> CLIArguments? {
        if arguments.town.isEmpty {
            return nil
        }
        return arguments
    }

    private func makeArgs() {
        populate(Array(input.dropFirst()))
    }

    private func populate(args: [String]) {
        for (index, item) in args.enumerate() {
            if args.count > index + 1 {
                let it = item.lowercaseString
                if it == "-t" || it == "--town" {
                    arguments.town = args[index + 1].lowercaseString
                } else if it == "-c" || it == "--country" {
                    arguments.country = args[index + 1].lowercaseString
                } else if it == "-s" || it == "--help" {
                    let value = args[index + 1].lowercaseString
                    if value == "short" {
                        arguments.style = .MiniString
                    } else if value == "normal" {
                        arguments.style = .String
                    }
                }
            }
        }
        if arguments.style == nil {
            arguments.style = .DetailedString
        }
    }

}

Maintenant remplacez le contenu de “main.swift” par ceci :

let cli = CLI(input: NSProcessInfo.processInfo().arguments)

if let args = cli.getArgs() {
    let wm = WeatherMan()
    if let country = args.country {
        wm.printCurrentWeather(args.town, country: country, style: args.style)
    } else {
        wm.printCurrentWeather(args.town, style: args.style)
    }
} else {
    print(cli.errorMessage)
    print(cli.helpMessage)
    exit(1)
}

NSRunLoop.currentRunLoop().run()

Bien sûr il y aurait encore beaucoup à améliorer : il faudrait séparer un peu mieux les responsabilités des méthodes, être plus cohérent dans la nomenclature, avoir un control flow plus souple, intégrer plus encore CLI pour libérer le côté main de tout travail, mieux gérer les erreurs, etc, mais tout de même, nous voilà ravis de notre petite application !

Distribution

Il est temps de générer un exécutable à distribuer pour notre application.

Jusqu'à présent dans Xcode nous avons généré l'exécutable avec le profil par défaut, c'est-à-dire en mode “debug”.

Pour optimiser l'exécutable, il faut en fait “archiver” le produit dans Xcode : menu “Product”, “Archive”.

Après compilation, on obtient ce panneau :

Cliquez sur “Export…” à droite, puis sélectionnez “Save Built Products” et sauvegardez dans le dossier de votre choix.

Entrez ensuite dans ce dossier et allez y chercher votre exécutable, puis copiez-le sous le nom “weatherman” dans /usr/local/bin :

cd /Users/me/temp/WeatherManCLI\ 2016-06-11\ 21-16-29
cp WeatherManCLI /usr/local/bin/weatherman

Redémarrez votre Terminal (ou faites source ~/.bash_profile ou source ~/.zshrc selon votre shell) - vous avez installé l'app et êtres maintenant capables de l'exécuter depuis n'importe où :

weatherman -t paris -c fr

Si vous teniez à distribuer l'exécutable au public, alors idéalement il faudrait créer un installeur, script ou app, qui copierait lui-même le fichier sur la machine cible puis mettrait à jour l'environnement shell.

Conclusion

Je n'ai pas pu tout expliquer car sinon ce tutoriel aurait été interminable (déjà que…) mais nous avons utilisé de nombreux concepts et de nombreuses techniques en faisant cette microscopique application.

Pour aller plus loin, il faudrait créer une app iOS ou OS X, prendre tous les fichiers de notre code (excepté “main.swift”, “CLI.swift” et “CLIArguments.swift” qui n'auraient plus d'utilité) et les copier dans ce nouveau projet, et construire une interface pour exploiter notre cher Monsieur Météo en coulisses, ajouter des beaux icones, des photos, des prévisions, etc.

Une prochaine fois peut-être ! ;)

Le projet Xcode est disponible sur GitHub.

Auteur: Eric Dejonckheere