/ Team iOS

Memory leaks ? Breakpoints & ViewControllers

Il arrive parfois que des fuites mémoires (memory leaks pour les intimes) s'invitent dans votre code. Elles sont néfastes pour votre application et vous devez absolument de les éradiquer ⚔️ ☠️ ??.

Un problème de fuites ?

Pour ce faire, encore faut-il les détecter... via un outil comme Instruments par exemple. Cependant d'autres techniques existent et celle qu'un de mes éminents collègues, Jeffrey Macko pour ne pas le nommer, m'a récemment montrée après avoir vu passer quelques tweets sur sa timeline Twitter [1][2] mérite vraiment le détour...

En partant de la possibilité d'ajouter des points d'arrêt dans Xcode afin de pouvoir afficher différents messages dans votre console si certaines conditions sont satisfaites, il nous propose un moyen de suivre les memory leaks de tous les ViewControllers de notre application...

Des breakpoints tracent allocations & desallocations de mémoire

Pour commencer, Jeff ajoute deux points d'arrêt (symbolic breakpoint) permettant d'afficher les allocations puis les désallocations de mémoire de tout view controller dans la console. En gros à chaque fois que de la mémoire est allouée pour un view controller et à chaque fois qu'elle est désalouée, la console print un joli petit message...

Mise en place des deux breakpoints

Voici les deux points d'arrêt puis les lignes à copier/coller pour ceux qui ont la flemme de tout recopier à la mano...

breakpoints_leaks_viewdidload

breakpoints_leaks_dealloc

!(BOOL)([[$arg1 description] containsString:@"Input"])

-- alloc %H -- @(id)[$arg1 description]@ @(id)[$arg1 title]@
-- dealloc @(id)[$arg1 description]@ @(id)[$arg1 title]@

Ces points d'arrêt vont afficher les ViewControllers à chaque fois que le système appelera leurs viewDidLoad et dealloc. Ceci nous permettra de vérifier, en naviguant dans toute l'application, qu'à chaque allocation correspond bien une desallocation de mémoire.

En fonction du parcours effectué dans l'application, on ne devrait avoir qu'une partie des ViewControllers restant alloués. Par exemple si on enchaine les écrans: Home -> Moteur de recherche -> Liste des Réponses (LR) -> Fiche Détaillée (FD) -> Dépôt d'avis -> puis qu'on retourne à la Home, on ne devrait plus avoir ni Moteur, ni LR, ni FD, ni Dépôt d'avis restant alloué en mémoire.

Résultats affichés dans la console

-- alloc 1 -- @"<PagesJaunes.TabBarViewer: 0x7fd70861df50>" 0x0
-- alloc 2 -- @"<UINavigationController: 0x7fd709052200>" 0x0
-- alloc 5 -- @"<PagesJaunes.HomeViewer: 0x7fd708703190>" 0x0
-- alloc 9 -- @"<PJEngineViewer: 0x7fd70843fa80>" 0x0

-- dealloc 1 -- @"<PagesJaunes.InternalFDViewController: 0x7fd70a319280>" 0x0
-- dealloc 2 -- @"<UINavigationController: 0x7fd70c1d6400>" 0x0
-- dealloc 5 -- @"<PJLRMapBICollectionViewController: 0x7fd70857cbf0>" 0x0
-- dealloc 9 -- @"<PJEngineSearchFieldsViewer: 0x7fd7086420d0>" 0x0

Remarque: j'ai un peu allégé les logs pour que cela soit plus lisible

Bien entendu, lire des dizaines de lignes de logs de ce type n'est ni très sûr (nous sommes des êtres humains, en tout cas pour la majorité d'entre nous), ni très efficace car nous allons forcément finir par passer à côté d'une info à force de lire ces lignes absconses. C'est pour cela que notre ami Jeff (c'est un vrai programmeur Jeff, il automatise tout ce qui est répétitif et/ou chronophage). En plus, comme il est sympa, il nous les file ses petits scripts...

"Il est un peu crappy celui-là mais il fait bien le taff...".

Le script qui fait le boulot

La sortie

Donc Jeff nous a pondu un bout de code en Swift[3], à qui on file la sortie console obtenue précédemment en entrée et qui nous renvoie une liste des ViewControllers ainsi qu'un compteur de leur alloc/dealloc... Je vous met le code un peu plus bas mais d'abord je vous montre qu'il nous liste facilement quels view controllers leak (??) et lesquels ne leak pas (??) :

?? PJEngineViewer[1] nonDealloc(0) Dealloc(1), 
?? FBTweakViewController[1] nonDealloc(0) Dealloc(1), 
?? PagesJaunes.MapViewController[1] nonDealloc(0) Dealloc(1), 
?? PagesJaunes.FavoritesViewController[1] nonDealloc(1) Dealloc(0), 

Ici, on observe que cette sortie nous montre clairement que PagesJaunes.FavoritesViewController n'est pas désalloué au moment où il le devrait...
Il y a donc une belle fuite mémoire ? qu'il va falloir étudier de plus près.

Sympathique non ?

Le code en Swift

Lorsque je lui ai demandé d'où il sortait ce code, Jeff m'a confié qu'il l'avait écrit dans la ligne 9 du métro parisien, avec Playground sur son iPad Pro... vous pouvez le trouvez directement sur ce gist github

struct AllocDeallocTester {

  struct AllocationResult : CustomDebugStringConvertible {
    let className : String
    var pointerNumber : Int = 0
    var numberOfMissDealloc : Int = 0
    var numberOfValidDealloc : Int = 0

    init(className inputClassName: String) {
      self.className = inputClassName
    }

    var debugDescription: String {
      get {
        let state = numberOfMissDealloc == 0 ? "??" : "??"
        return "\n\(state)\(className)[\(pointerNumber)] nonDealloc(\(numberOfMissDealloc)) Dealloc(\(numberOfValidDealloc))"
      }
    }
  }

  enum AllocType {
    case alloc
    case dealloc
  }

  struct LigneResult {
    let className : String
    let pointer : String
    var type : AllocType = .alloc
  }

  static func matches(for regex: String, in text: String) -> [String] {

    do {
      let regex = try NSRegularExpression(pattern: regex)
      let nsString = text as NSString
      let range = NSRange(location: 0, length: nsString.length)
      let results = regex.matches(in: text, options: [], range: range)

      var output : [String] = []
      output.append(nsString.substring(with: (results.first?.range(at: 1))!))
      output.append(nsString.substring(with: (results.first?.range(at: 2))!))
      output.append(nsString.substring(with: (results.first?.range(at: 3))!))
      return output
    } catch let error {
      print("invalid regex: \(error.localizedDescription)")
      return []
    }
  }

  static func getLigneData(inputString : String) -> LigneResult? {
    let regexPattern = "-- (alloc|dealloc) \\d+ -- @\"<(.*): (.*)>"
    let res = matches(for: regexPattern, in: inputString)
    if res.count != 3 {
      return nil
    }
    let alloc = (res[0] == "dealloc") ? AllocType.dealloc : AllocType.alloc
    let className = res[1]
    let pointer = res[2]
    return LigneResult(className: className, pointer: pointer, type: alloc)
  }

  static func filterByPointer(lignes : [LigneResult]) -> [String:[LigneResult]] {
    var pointerOriented : [String:[LigneResult]] = [:]
    for aLigne in lignes {
      if let _ = pointerOriented[aLigne.pointer] {
        pointerOriented[aLigne.pointer]?.append(aLigne)
      } else {
        pointerOriented[aLigne.pointer] = [aLigne]
      }
    }
    return pointerOriented
  }

  static func filterByClassName(lignes : [LigneResult]) -> [String:[String : [LigneResult]]] {
    var pointerOriented : [String:[LigneResult]] = [:]
    for aLigne in lignes {
      if let _ = pointerOriented[aLigne.className] {
        pointerOriented[aLigne.className]?.append(aLigne)
      } else {
        pointerOriented[aLigne.className] = [aLigne]
      }
    }
    var res : [String:[String : [LigneResult]]] = [:]
    for (className, lignes) in pointerOriented {
      res[className] = filterByPointer(lignes :lignes)
    }
    return res
  }

  static func transformLigneDataToResult(lignes : [LigneResult])  -> [AllocationResult] {

    var output : [AllocationResult] = []

    let filteredLignes = filterByClassName(lignes: lignes)

    for (className, pointerLigne) in filteredLignes {
      var allocR = AllocationResult(className: className)
      allocR.pointerNumber = pointerLigne.count
      var numberOfValidDealloc = 0
      var numberOfMissDealloc = 0
      for (pointer, lignes) in pointerLigne {
        let allocs = lignes.reduce(0, { (res, l) in
          if (l.type == .alloc) {
            return res + 1
          }
          return res + 0
        })
        let deallocs = lignes.reduce(0, { (res, l) in
          if (l.type == .dealloc) {
            return res + 1
          }
          return res + 0
        })
        if allocs - deallocs == 0 {
          numberOfValidDealloc = numberOfValidDealloc + 1
        } else {
          numberOfMissDealloc = numberOfMissDealloc + 1
        }
      }
      allocR.numberOfValidDealloc = numberOfValidDealloc
      allocR.numberOfMissDealloc = numberOfMissDealloc
      output.append(allocR)
    }

    return output
  }

  public static func generateAllocationResult(inputTexte : String) -> [AllocationResult] {
    let lines = inputTexte.components(separatedBy: .newlines)
    let results = lines.flatMap(getLigneData)
    let allocationsStats = transformLigneDataToResult(lignes : results)
    return allocationsStats
  }

}

let res = AllocDeallocTester.generateAllocationResult(inputTexte: inputAllocation)
print(res)

Merci qui ? Merci Jeff !


  1. Le tweet de Cédric Luthi (@0xced): https://twitter.com/0xced/status/900692839557992449 ↩︎

  2. Le tweet d'Alejandro Ramirez (@j4n0): https://twitter.com/j4n0/status/900742629297713152 ↩︎

  3. Le code Swift: https://gist.github.com/jgodon/0cd914e86151ecf0ed2714467524e084 ↩︎