Hi, I’m Ben. I’m building an app called Poesie to share my love of the arts around the world. I hope that sharing some of my experiences will help and inspire other entrepreneurs. And if this project sounds interesting to you… well I’m looking for a cofounder!! You can get in touch by email: firstname.lastname@example.org.
Last week, Poesie experienced its first memory-size-related app crash. Since introducing lots more content into the app, including gorgeous visual art, we’ve needed to be much more careful about data management. After a few occasions in which Poesie creeped its way near the Top 10 apps in my “iPhone Storage” hit-list (prime candidates for app deletion!), I carved out this week for Poesie’s first eng quality sprint. The main goals were to address that memory crash and to see if we could keep Poesie below 100MB in the iPhone storage list over the course of a deep usage session. Along the way, we also earned a few UI perf wins. I will detail the lessons here for others to learn from.
The three concepts we will look at are: (1) avoiding retain cycles, (2) being thoughtful about storing and rendering images, and (3) optimizing view load performance.
Disclaimer: Before we start, I want to make it very clear that I am not a very experienced iOS engineer and do not consider myself a resource for best practices. I’ve learned what I can to build my app and continue to learn each step of the way. My hope is for takeaways from this post to not be about the solutions themselves, but rather about the problems and the processes! Here we go.
First: Crashlytics, a tool I encourage you to use.
Here’s what the memory error looked like:
If you haven’t seen this tool before, it is called Crashlytics. Installing Crashlytics (https://try.crashlytics.com/) has been powerful for my development process. I was previously using xcode’s built-in crash logging. This tool does a better job of tracking errors over time and alerting me to new issues; thanks to Crashlytics, I have crash rate as a key eng metric and have debugged many outstanding issues.
Though I could have spent much more time learning about NSAllocateMemoryPages, I gathered that the gist of the issue involved using up too much memory. In retrospect, it is no surprise — since adding lots of images to accompany poetry, music, and stories in my app, I severely increased the memory load.
Issue 1: Avoiding Retain Cycles
TLDR: Use XCode’s lovely “memory graph” debugging tool to discovery and remove bad retain cycles.
Well, I’d heard of them but never looked into it. Now, the time had come. In short, a retain cycle is caused by a model whose pieces are self-referential. Because each of the two parts of the model “require” the other to be exist, it becomes impossible to deallocate the entire model. This means that potentially unnecessary objects will avoid deallocation and clog up the memory.
The problem: In my case, the retain cycles were caused by bad implementation of my object graph. My object graph includes multiple collections of objects that map to one another. I thought it would be nice to set up dictionaries of pointers between instances for easy access; but, without declaring them as weak, this created a retain cycle. I often destroy and reload objects when refreshing my main content libraries, but old objects were stuck in memory due to these cyclic references.
The solution: The “debug memory graph” tool in xcode was my biggest friend here. A few taps immediately showed where I had retain cycles, in all the expected model definitions. I fixed these with combinations of (a) making any instance references “weak”, and (b) in cases where I was using more complicated dictionaries, only storing unique object IDs and letting my global master library look up and deliver arrays of values. Note: For those who are new to the game, an object which is pointed to by a “strong” reference will never be allowed to deallocate until its “strong” owner is deallocated; an object pointed to by a “weak” reference is allowed to be automatically deallocated by iOS should there be no other “strong” references to that object. In my updated framework, “X is owned by Y” is always a weak reference and “X and Y are horizontally related” (pictured above) is also always a weak reference.
Issue 2: Storing and Rendering Images
TLDR: Monitor the size of image data you are downloading and set up a metered cache for decoded UIImages.
After introducing lovely artwork across multiple surfaces of my app, I began to experience sessions in which nearly 50MB+ of memory was taken up by images. This, combined with app size and other information downloaded, was bringing my app to the top of the “iPhone storage” list when in use.
Before: I was downloading large images of artwork (sometimes up to MBs large!) from the internet directly to my object model, persisting them as UIImage in memory, and doing no clean-up after rendering them. Once they are decoded for rendering, they take up even more memory. This cost was stacking up over the course of a session. The main mistakes here were (1) querying and downloading images of quality that I really didn’t need, (2) and storing them all as UIImage in my model, even when I wasn’t accessing them for rendering on a regular basis.
I introduced the following systematic changes:
- All images are required to be fetched with a specified desired size. If the server/internet has a pre-created image fitting that size, I will return that and avoid any waste. (Great!). If not, I may actually make the choice to resize and save a smaller version upon receiving the data.
- All images are downloaded and saved as Data objects. They will not be unpacked to UImage until they are needed. Data Constraint 1 (Disk): If the amount of data I have saved to disk in a given session comes above a certain amount, I started to either downsize or delete the largest images.
- When rendered, data is extracted to a UIImage for the size needed and stored in a NSCache. Data Constraint 2 (Memory): This NSCache is purged after it reaches a certain size.
Along the way, I followed the best practices recommended by Apple around extracting data to UIImage, including downsampling and using a dedicated DispatchQueue for the work. (See: https://developer.apple.com/videos/play/wwdc2018/219/)
Some of my choices are certainly app-specific: I download hundreds-to-thousands of artwork, so the memory footprint does matter; I have 3 vastly different size requirements (small thumbnail, medium size, full screen), so there is room for size-based optimization; and there is a lot of scrolling/flipping back and forth, so the Caches are quite useful.
After these changes, I have a much better grasp on the memory footprint my app is allowed to use! I can then use my knowledge of typical app usage flows to set UIImage cache + disk size limits that are reasonable for the user experience while still in line with my comfort level for the app.
Issue 3: View Loading Times
TLDR: Use XCode’s Time Profiler to identify heaviest pieces of code and push back any unnecessary work while views are being rendered.
While rewriting some of my UIImage rendering code after the previous fixes, I found some opportunities to improve the dreaded flip animation in my UIPageControllerView.
The problem: I have a UIPageControllerView that flips between fairly complicated (images, table views, text, etc.) ViewControllers. The flip was slow to start and also hanging right before finishing. I wanted this flip — which is the most popular action in my entire app! — to feel as smooth as possible. After learning that both the ViewDidLoad and the ViewDidAppear functions were invoked during the page flip transition, I had to do better than just pushing back work into the ViewDidAppear.
To solve this, XCode instruments CPU profiler was my biggest friend. I highly encourage everyone to learn and play around with it if they get a chance! The following lessons became some of my biggest wins:
- Avoid setting UITableView dataSources before they are actually needed. Setting UITableView dataSources in ViewDidLoad was causing a few cells to render and wasting valuable time. It is better to leave these as nil and then invoke/reload the data when you can or need.
- Avoid any unnecessary image processing. I hadn’t realize that a few of these operations could cause a visual stutter. I was doing some image resizing during ViewDidLoad for button rendering. Avoid this by having correct images available ahead of time or find implementations that wait until the end of the layouts.
After these updates, my flips are looking much smoother.
To summarize, the lessons from this sprint included (1) always checking for retain cycles, (2) optimizing image memory footprint, (3) and ruthlessly cleaning up view load times. After this eng sprint, the app has been comfortably below 100MB for me on the iPhone Storage list and some of our core user interactions are looking much better.
There is still lots of room for improvement, but as long as the loading or scrolling isn’t too jittery, and the memory usage remains below a reasonable threshold, I’m happy to move on to more feature work!
Can you do this 100x better than me? Are you chuckling to yourself right now about how silly these lessons are? But, do you also love the arts and are just so glad that someone is building this product?? Come build Poesie with me! We have thousands of users, strong retention, and great user feedback. I want to grow, and am looking for a co-founder. Contact me at email@example.com.