Jeu mobile en React Native : retour d'expérience
Nouveau challenge cette semaine, apprendre React, React Native et développer un jeu de puissance 4 pour iOS.
On doit pouvoir jouer à deux sur un périphérique ou contre une intelligence artificielle.
React Native, c'est quoi ?
Pour expliquer ce qu'est React Native, il faut déjà voir ce qu'est React.
React
Je n'avais jamais fait de React mais j'ai par contre déjà travaillé avec pas mal de frameworks web javascript : backbone, angular 1 & 2, meteor... et comme ça fait un certain temps que React a le vent en poupe et que je me promets de le tester pour me faire un avis personnel, ça fait une super occasion !
De ce que j'ai pu aperçevoir, React est un framework vraiment basé sur l'interface graphique, on créé donc des composants graphiques, qui ont de manière complètement indépendante un template, des évènements, des paramètres, un état... Ces composants peuvent également avoir des sous composants qui eux-même auront des sous-composants, etc.
On a un seul super composant qui englobe tous les autres, un peu comme un root en XML.
Dans le cas du puissance 4 on a donc un arbre de composants qui ressemble à ce schéma.
Chacun des composants peut gérer des données via un état et le changement de cet état déclenche un rafraîchissement de ce composant et de tous les sous-composants liés à cet état. Quand on pense React, on essaye donc de limiter au maximum les données qu'un même composant gère et de séparer les gros composants en plusieurs sous-composants pour éviter de tout rafraîchir à chaque fois inutilement.
Pour comparer avec Angular, je trouve que les concepts ressemblent beaucoup aux directives, ou à Angular 2 où on reprend d'ailleurs aussi le nom de composant. Là où React se différencie, c'est que les composants embarquent les templates via du JSX et même le CSS (ou équivalent) si nécessaire.
import React from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
const styles = StyleSheet.create({
button: {
backgroundColor: "#5cb85c",
borderRadius: 20,
padding: 10,
margin: 5,
},
text: {
fontSize: 16,
textAlign: "center",
color: "white",
},
});
const Button = ({ onPress, text, style = null }) => (
<View>
<TouchableOpacity onPress={onPress} style={[styles.button, style]}>
<Text style={styles.text}>{text}</Text>
</TouchableOpacity>
</View>
);
Button.propTypes = {
onPress: React.PropTypes.func,
text: React.PropTypes.string.isRequired,
style: View.propTypes.style,
};
export default Button;
Native
Donc React, c'est fait pour le web, on génère du HTML et donc un DOM en se basant sur des composants. Et bien React Native c'est exactement la même chose, sauf qu'on ne génère pas du tout de DOM, mais bien une application native !
D'ailleurs, le client React Native créé bien un projet XCode, qu'on peut exécuter et modifier directement dans l'éditeur.
L'approche est donc complètement différente des frameworks comme meteor qui parie sur cordova et qui s'appuye sur des webviews pour générer des applications à travers une page web. Ici on a bien une vraie application !
L'avantage d'avoir une vraie application est que l'expérience utilisateur est améliorée puisqu'on utilise des composants natifs mieux intégrés dans l'OS du périphérique et également plus rapides.
Il y a forcément quelques compromis, tous les composants ne fonctionnent pas sur tous les terminaux, il ne s'agit donc pas complètement de code cross-plateforme.
Cet exemple entre le Picker
classique et le PickerIOS
montre la différence d'affichage entre les deux composants.
Heureusement, les APIs tendent à converger avec les mêmes noms de méthode, de propriétés... et surtout, React native propose de compiler en fonction de la plateforme cible. On pourra donc avoir un fichier MonPicker.android.js
et un autre MonPicker.ios.js
utilisant des composants différents. Il nous suffit alors d'avoir un fichier commun aux deux pour mutualiser la logique sans mutualiser la vue.
Un bon exemple de cette logique peut être vu dans une calculatrice faite par Benoît VALLON:
// KeyboardRender.js
import Key from "./Key";
import React from "react";
export default function() {
return (
<div className="keyboard">
<div className="keyboard-row">
<Key keyType="number" keyValue="1" keySymbol="1" />
<Key keyType="number" keyValue="2" keySymbol="2" />
<Key keyType="number" keyValue="3" keySymbol="3" />
</div>
//[...] omitted for brevity
<div className="keyboard-row">
<Key keyType="action" keyValue="back" keySymbol="<<" />
<Key keyType="action" keyValue="equal" keySymbol="=" />
</div>
</div>
);
}
//KeyboardRender.native.js
import Key from "./Key";
import React, { StyleSheet, View } from "react-native";
export default function() {
return (
<View style={styles.keyboard}>
<View style={styles.row}>
<Key keyType="number" keyValue="1" keySymbol="1" />
<Key keyType="number" keyValue="2" keySymbol="2" />
<Key keyType="number" keyValue="3" keySymbol="3" />
</View>
//[...] omitted for brevity
<View style={styles.row}>
<Key keyType="action" keyValue="back" keySymbol="<<" />
<Key keyType="action" keyValue="equal" keySymbol="=" />
</View>
</View>
);
}
Ici on a bien deux composants différents, KeyboardRender.js
prévu pour le web en React simple et KeyboardRender.native.js
prévu pour le mobile en React Native.
import React from "react";
import Screen from "./Screen";
import Formulae from "./Formulae";
import Keyboard from "./Keyboard";
export default function() {
return (
<div className="main">
<Screen />
<Formulae />
<Keyboard />
</div>
);
}
Lorsqu'on appelle le composant, il suffit juste d'ajouter un import et React Native se chargera d'importer le bon fichier en fonction de la plateforme cible et de son extension.
Expérience de développement
Quelque-chose qui m'a agréablement surpris avec React native, l'expérience de développement proposée est vraiment géniale ! On se rapproche du web dès que possible...
Le debugger
On peut tout simplement ouvrir un onglet dans chrome et positionner des points d'arrêt dans le code qui tourne sur le simulateur. Et à l'aide des source maps, on se croirait vraiment en train de debugger une application javascript web classique, c'est un système très familier et ça facilite donc énormément son accès.
Le style
Toujours dans la même logique, React Native utilise un langage de style se rapprochant du CSS. On peut donc intégrer les styles directement dans nos composants sans se référer à une nouvelle API ou comprendre un nouveau système de layout, on applique encore une fois les standards du web !
const styles = StyleSheet.create({
cell: {
padding: 5,
alignItems: "stretch",
flex: -1,
},
disc: {
flex: -1,
borderRadius: 20,
height: 40,
width: 40,
},
});
J'ai trouvé ça assez étrange au début de forcer le style au composant, surtout pour les composants génériques où il va plutôt dépendre du parent.
const Cell = ({ color, style = null }) => (
<View style={[styles.cell, style]}>
<View style={[styles.disc, styles[color]]} />
</View>
);
Cell.propTypes = {
color: React.PropTypes.number.isRequired,
style: View.propTypes.style,
};
En fait, tout est prévu, les styles inclus dans les composants sont fait pour des règles très basiques et l'appelant peut passer un autre style qui viendra alors surcharger celui par défaut. Une sorte de cascading en utilisant de l'objet !
L'architecture
Plusieurs questions se sont posées quant à l'organisation de l'architecture du projet. Dans la plupart des articles et tutoriels à ce sujet, on voit souvent tous les fichiers rangés par nature. Composants, classes et actions sont tous dans leurs propres dossiers, ça fait rapidement un bon paquet de fichiers par dossier.
Quand on développe, on est en général sur une fonctionnalité précise, on modifie rarement tous les composants en une seule fois.
Chez marmelab, on préfère donc adopter une organisation par domaine. On a tous les fichiers d'une même fonctionnalité dans le même dossier et c'est vraiment plus simple que d'avoir 4 ou 5 fichiers ouverts dans des dossiers différents.
src
├── app
│ ├── PlayPage.js
│ └── WelcomePage.js
├── chrome
│ └── Button.js
├── connectfour
│ ├── board
│ │ ├── AddDisc.js
│ │ ├── Board.js
│ │ ├── BoardModel.js
│ │ ├── Cell.js
│ │ ├── ColorHelper.js
│ │ └── ControlButton.js
│ ├── game
│ │ ├── ComputerGameModel.js
│ │ ├── GameModel.js
│ │ ├── HumanGameModel.js
│ │ ├── PlayTurn.js
│ │ └── SwitchPlayers.js
│ └── player
│ ├── PlayerBadge.js
│ └── PlayerModel.js
└── tool
├── ArrayConsecutive.js
└── ArrayTransposer.js
En plus, quand on veut réutiliser un composant dans un autre projet, c'est d'autant plus simple de l'externaliser et de modifier son application en conséquence car tous les imports pointent vers le même endroit.
Mes tests sont restés dans le dossier test
pour le moment mais je vois déjà, même avec cette taille d'application, que ce serait plus pratique qu'ils soient intégrés aux domaines.
Si on plonge un peu plus dans un domaine, on se rend compte qu'on a différents types de fichiers.
src/connectfour/board
├── AddDisc.js
├── Board.js
├── BoardModel.js
├── Cell.js
├── ColorHelper.js
└── ControlButton.js
Les fichiers sans suffixe Board.js
, Cell.js
et ControlButton.js
sont de simples composants React Native.
Le fichier BoardModel.js
(et tous ceux avec le suffixe Model
) construit simplement des objets Board
immutables, on a donc des méthodes d'initialisation et de vérification de l'état du plateau de jeu, rien de plus.
export default class Board {
constructor(width, height) {
this.cells = [];
this.initializeCells(width, height);
}
initializeCells(width, height) {
this.cells = Array.from(Array(width), () =>
Array(height).fill(colors.empty)
);
}
isColumnFull(columnNumber) {
return this.cells[columnNumber].every(cell => cell !== 0);
}
isFull() {
return this.cells.every(column => column.every(cell => cell !== 0));
}
//[...] omitted for brevity
}
Toutes les méthodes ayant un impact sur le plateau de jeu sont elles dans des fichiers séparés. Ces méthodes restent pures puisqu'elles ne modifient pas directement leur environnement mais retournent un nouvel objet.
import update from "react-addons-update";
export default function addDiscToBoard(board, columnNumber, color) {
const column = board.cells[columnNumber];
let rowNumber = -1;
for (rowNumber = column.length - 1; rowNumber >= 0; rowNumber -= 1) {
if (column[rowNumber] === 0) {
break;
}
}
return update(board, {
cells: {
[columnNumber]: {
[rowNumber]: {
$set: color,
},
},
},
});
}
Garder le côté pur de la méthode, ça implique (entre autres) qu'il faut dupliquer l'objet en entrée pour le modifier. C'est assez fastidieux à faire à la main mais React propose un helper permettant de mettre à jour un objet à la MongoDB avec des $set
, $push
, $merge
... Ca facilite vraiment les choses !
Les tests
Je suis initialement parti sur Jest pour gérer les tests, sauf qu'en React Native à part des tests de snapshots, il n'y a pas beaucoup de possibilité.
import "react-native";
import React from "react";
import Button from "../src/chrome/Button";
import renderer from "react-test-renderer";
it("renders correctly", () => {
const button = renderer.create(<Button text="CONNECT FOUR" />).toJSON();
expect(button).toMatchSnapshot();
});
Au premier passage des tests, un fichier de snapshot est généré.
exports[`test renders correctly 1`] = `
<View>
<View
accessibilityComponentType={undefined}
accessibilityLabel={undefined}
accessibilityTraits={undefined}
accessible={true}
hitSlop={undefined}
onLayout={undefined}
onResponderGrant={[Function bound touchableHandleResponderGrant]}
onResponderMove={[Function bound touchableHandleResponderMove]}
onResponderRelease={[Function bound touchableHandleResponderRelease]}
onResponderTerminate={[Function bound touchableHandleResponderTerminate]}
onResponderTerminationRequest={[Function bound touchableHandleResponderTerminationRequest]}
onStartShouldSetResponder={[Function bound touchableHandleStartShouldSetResponder]}
style={
Object {
"backgroundColor": "#5cb85c",
"borderRadius": 20,
"margin": 5,
"opacity": 1,
"padding": 10
}
}
testID={undefined}>
<Text
accessible={true}
allowFontScaling={true}
ellipsizeMode="tail"
style={
Object {
"color": "white",
"fontSize": 16,
"textAlign": "center"
}
}>
CONNECT FOUR
</Text>
</View>
</View>
`;
Puis à tous les passages suivants, le résultat du test est comparé au snapshot. C'est certes rapide et ça test effectivement le composant mais ça ne décrit pas du tout le comportement attendu, j'ai donc cherché d'autres solutions.
React native n'ayant pas vraiment de DOM, toutes les bibliothèques reposant sur du full dom rendering ne fonctionnent pas. Je suis donc parti sur une stack de test enzyme (en mode shallow dom rendering), mocha, chai, sinon, chance, en me basant sur cet article très bien expliqué.
Du coup, avec tout ça en place, les tests sont plutôt simples et agréables à lire.
it("should call the method with the right arguments when clicked", () => {
const controlButton = shallow(
<ControlButton onPress={dropDiscCallback} column={0} />
);
controlButton.find(Button).simulate("press");
expect(dropDiscCallback.called).to.be.true;
expect(dropDiscCallback.args[0][0]).to.be.equal(0);
});
it("should not do anything on click if disabled", () => {
const controlButton = shallow(
<ControlButton enabled={false} onPress={dropDiscCallback} column={0} />
);
controlButton.simulate("press");
expect(dropDiscCallback.called).to.be.false;
});
L'AI qui prends en compte vos sentiments
L'AI faisait parti d'un challenge précédent et a été réalisée en Go, l'objectif ici était de l'adapter pour qu'elle puisse être appellée depuis l'application iOS.
Cette AI se base sur plusieurs principes :
- MinMax pour déterminer la meilleure colonne à jouer en fonction d'un scoring des possibilités.
- Convolution pour déterminer le nombre de disques alignés de suite dans le plateau.
- Les channels et goroutines permettant de rendre une partie de l'algorithme asynchrone et de par exemple retourner le meilleur résultat trouvé dans un temps imparti
Domaine complètement étranger pour moi qui suis plutôt habitué aux applications de gestion, on ne peut pas dire que l'AI soit super intelligente, mais je préfère dire qu'elle est sympa avec le joueur !
La fonction de scoring d'une board est bien plus compliquée que ce à quoi je m'attendais, puisqu'elle doit prendre en compte les possibilités de gagner, mais aussi les possibilités de perdre.
J'espère que ma prochaine AI sera plus cruelle !
Conclusion
React & React Native sont deux frameworks qui valent vraiment le coup. Le premier permet de bien structurer son code JavaScript et le rendre facilement maintenable si les bonnes pratiques sont respectées; l'autre permet d'avoir une application native en moins de deux, avec un langage et des principes qu'on connait déjà !
Certes la codebase n'est pas tout à fait commune entre les plateformes, mais pour tous ceux qui cherchent une expérience native, c'est de loin la meilleure solution.
Quant à mon intégration... mes pull-requests sont de plus en plus propres, les tests de plus en plus nombreux et les code reviews de moins en moins fournies; et ça fait du bien ! Je commence aussi à gagner quelques parties de babyfoot...
Note: L'application React native et le serveur Go sont disponibles sur GitHub:
A lire sur le sujet:
- Tutorial officiel React, pour une montée en compétence efficace
- Article expliquant les composants stateless, toujours d'actualité
- Article expliquant l'immutabilité avec React
- Série d'article sur React & ES6, avec notamment les alternatives au method binding explicite