Les nouveautés de Core Data sous iOS 8 (Partie 2)

Par Pierre Felgines, le 16.10.2014

Dans notre dernier billet sur les nouveautés de Core Data dans iOS 8, nous avons exploré les NSBatchUpdateRequest. Cette fois-ci nous allons nous concentrer sur une autre nouveauté : les requêtes asynchrones (asynchronous fetch requests).

Requêtes fetch asynchrones

La seconde nouveauté qu’Apple a introduite dans Core Data pour iOS 8 est la possibilité d’exécuter des fetch requests de manière asynchrone et donc de programmer un long fetch en arrière-plan. Pour ceci, une autre sous classe de NSPersistentStoreRequest a été introduite : NSAsynchronousFetchRequest.

Récupérer des objets de la base de données peut être une opération couteuse en temps pour différentes raisons : un grand nombre de lignes dans la table, des prédicats compliqués, l’utilisation de sort descriptors, etc. Si cette longue opération est exécutée en premier plan, le thread principal, responsable de l’interface utilisateur, va se bloquer et l’application ne sera plus réactive. Grâce aux fetch requests en arrière plan, la récupération des données peut maintenant être relégué à une queue spécifique en arrière-plan, tout en laissant le thread principal s’occuper de l’UI.

Une question naturelle est de se demander pourquoi ne pas utiliser GCD pour effectuer une fetch request normale en arrière plan ? Il y a deux raisons à ça.

  • La première est pragmatique : le faire avec GCD est laborieux. En effet il faut instancier un nouveau NSManagedObjectContext dans le thread en arrière-plan, créer une NSFetchRequest qui récupère les ids des objets de la base de données, passer ces ids au thread principal, et finalement charger les NSManagedObject qui correspondent aux ids récupérés.
  • La seconde raison c’est que sous certaines conditions, les fetch requets asynchrones permettent de suivre la progression de la requête en base de données et de l’annuler si elle devient obsolète. Nous y reviendrons un peu plus loin.

En pratique

Regardons comment utiliser les NSAsynchronousFetchRequest sur un exemple. Imaginons à nouveau que nous disposions d’une collection d’articles en base de données. Nous voulons récupérer tous les articles et les trier par titre. Si il y a un grand nombre d’articles, cette opération peut être couteuse en temps.

Pour ceci, une NSFetchRequest est créée normallement.

// The fetch request we would normally use
NSFetchRequest * fetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"Article"];
fetchRequest.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"title"
                                                               ascending:YES]];

Puis, au lieu d’appeler [NSManagedObjectContext executeFetchRequest:error:], la requête est encapsulée dans une NSAsynchronousFetchRequest.

NSAsynchronousFetchRequest * asynchronousFetchRequest =
  [[NSAsynchronousFetchRequest alloc] initWithFetchRequest:fetchRequest
                                           completionBlock:^(NSAsynchronousFetchResult *result) {
  if (result.operationError) {
    /* Handle the error */
  } else {
    NSArray * articles = result.finalResult;
    dispatch_async(dispatch_get_main_queue(), ^{ /* update your UI on main thread */ });
  }
}];

Notons au passage que les articles fetchés sont accessibles dans le block de complétion et que le code d’UI doit être exécuté sur la queue principale.

Enfin, la méthode [NSManagedObjectContext executeRequest:error:] commence la requête asynchrone et retourne un NSPersistentStoreAsynchronousResult.

NSPersistentStoreAsynchronousResult * result = (NSPersistentStoreAsynchronousResult *)
  [self.managedObjectContext executeRequest:asynchronousFetchRequest error:NULL];

A noter : pour utiliser les NSAsynchronousFetchRequest, il faut faire attention à la manière dont on initialise le NSManagedObjectContext dans notre implémentation de Core Data. Sinon on risque d’être confronté à l’erreur suivante :

NSConfinementConcurrencyType context <NSManagedObjectContext: 0x...> cannot support asynchronous
fetch request <NSAsynchronousFetchRequest: 0x...> with fetch request <NSFetchRequest: 0x...>

Vous devez utiliser la méthode d’initialisation dédiée [NSManagedObjectContext initWithConcurrencyType:] avec un des types suivants :

  • NSConfinementConcurrencyType (par défaut) qui ne permet pas les requêtes asynchrones. Apple définit ce type comme obsolète et peu recommandé pour du code nouveau.
  • NSPrivateQueueConcurrencyType pour programmer la requête dans une queue privée.
  • NSMainQueueConcurrencyType pour programmer la requête dans la queue principale.

Suivre la progression

L’avantage des NSAsynchronousFetchRequest est la possibilité de suivre la progression du fetch en arrière-plan.

La méthode executeRequest, utilisée pour commencer le fetch, retourne un NSPersistentStoreAsynchronousResult directement. Ce résultat a une property NSProgress * progress. Le but de cette property (non documenté) est double : cet objet progress peut être utilisé pour suivre l’évolution de la requête asynchrone et pour l’annuler grâce à sa méthode cancel.

Pour suivre la progression de la requête, vous devez dans un premier temps fournir une valeur à la property estimatedResultCount de votre requête. Si vous ne le faites pas, Core Data ne sera pas capable de savoir combien d’objects sont supposés être récupérés et ne pourra pas calculer la progression.

Puis vous devez créer un NSProgress parent et vous enregistrer comme observateur de la clé @"fractionCompleted" du NSProgress enfant (celui du résultat de la requête).

// Create the parent progress
NSProgress * parentProgress = [NSProgress progressWithTotalUnitCount:1];
[parentProgress becomeCurrentWithPendingUnitCount:1];
//
/* ... execute the asynchronous fetch request and get the result */
//
// Register as observer for key @"fractionCompleted" for the fetch progress
[result.progress addObserver:self
                  forKeyPath:@"fractionCompleted"
                     options:NSKeyValueObservingOptionInitial
                     context:NULL];

Ce qui se passe ici, c’est que chaque fois qu’un nouvel objet est extrait de la base de données, Core Data divise le nombre d’objets récupérés total par la valeur de estimatedResultCount pour mettre à jour la valeur de la property fractionCompleted du progress.

fractionCompleted = fetchedObjectsCount / estimatedResultCount

Notons que si la valeur de estimatedResultCount est trop petite, c’est-à-dire qu’il y a plus d’objets à récupérer que l’on pensait, la valeur de fractionCompleted peut être plus grande que 100%.
C’est pourquoi Core Data met à jour de manière interne la valeur de estimatedResultCount comme ceci (pseudo-code) :

while(!finished) {
  /* ... fetch objects ... */
  if (fetchedObjectsCount >= estimatedResultCount) {
    estimatedResultCount = 2 * estimatedResultCount;
  }
  fractionCompleted = fetchedObjectsCount / estimatedResultCount;
  /* ... pass the fractionCompleted to the progress ... */
}

Le problème de cet algorithme est le suivant : si vous affichez la valeur de fractionCompleted dans une UIProgressView et fournissez une valeur de estimatedResultCount trop petite, vous allez observer un comportement étrange : la progression va augmenter jusqu’à 100%, puis revenir à une valeur plus basse, et ainsi de suite.

Mettre à jour la progression n’a de sens que si vous connaissez le nombre d’objets à récupérer à l’avance. Si ce n’est pas le cas, affichez juste un UIActivityIndicator pour notifier l’utilisateur qu’une opération se réalise en arrière-plan.

Conclusion

Comme nous l’avons vu, Apple fournit avec iOS 8 une nouvelle classe, NSAsynchronousFetchRequest, pour effectuer en arrière-plan une NSFetchRequest qui prend du temps. De plus, si vous avez une idée précise du nombre d’objets qui vont être récupérés lors de l’exécution de la requête, il est possible de suivre et d’afficher la progression de cette opération.

Nous espérons qu’une documentation sera disponible bientôt, et en attendant vous pouvez nous faire part de vos commentaires et de vos retours sur twitter.