Dev Notes #4: Image Caching, Custom Emoji, and a Dumb UIKit Bug04 Feb 2023
I started the week off addressing how the app caches images. The app uses my wrapper around ImageI/O. The downloader that uses relies on URLCache as well as an in memory cache of the parsed images on top of that. But, URLCache can be unpredictable to say the least. I like that it uses the response headers to determine how long to cache items. But from my experience, it often ejects items to agressively.
For Mastodon, image urls mostly1 change when they get updated. For instance if you update your avatar the url returned with your profile will be different. I can take adavantage of that to cache images by their URL and only ever remove them if I need to free up space. I am getting a little bit nervous thinking about Mastodon clones and other services in the fediverse. These kinds of assumptions could quickly fall apart, but that’s a problem for another day.
I do have a tendancy to overcomplicate things like this, but I wanted to support a few things.
First, I wanted to be able to handle background fetching, so I went with a really simple filename based caching system where all I need to do was move a download to the correct path based on it’s request url.
Second, I wanted to fallback to alternatives if one of the urls failed to download. Mastodon often provides multiple urls for any given images. Media attachments can include up to 4 urls: an original one, a cached one from your instance, and smaller preview sizes for each. The documentation says that the cached version is in case the original becomes unavailable because of load, but using mastodon.social, I’ve found the original to be much more reliable. Regardless, if the original fails, I want to try the cached url. If those both fail, I want to try the preview versions since a slow internet connection might cause the fullsize versions to timeout, but succeed at downloading a smaller version. This made the code a little bit ugly, but wasn’t too complicated to impliment.
And finally, I wanted to make sure that images were only available in memory once. So if 2 views request the same image (quit likely if we are talking about avatars in a conversation list) we don’t end up storing that image in memory more than we need. Swift Tasks make it pretty straightforward to return existing requests instead of starting new ones, but one extra complication was making sure existing images got reused. If you have a simple in memory cache (using NSCache or a Dictionary) that will avoid duplicate images being returned, but once you clear that cache, if an image gets requested again you will end up recreating it. The solution I found was to create a weak wrapper and store your returned items that way. Then if everything (including your cache) releases the image, it gets deallocated, but if anything is still holding onto it, you don’t create a duplicate.
Coupling that with preloading images (in the background when necessary), the experience paging through the timeline is much improved. Most things are already loaded when you get to them.
Following that I was looking at a long list of bugs, and was feeling quit demotivated. So instead I bumped up something that seemed more fun: custom emoji support.
I’ve tried to be fairly agressive about prioritizing what is the most important work because I really want to get this app on the store by Summer. For review, Mastodon lets admins add custom emoji that user’s can include in their posts and username using a shortcode syntax like
ghost. This isn’t necessarily a feature that is critical. Ivory shipped without it. If your app doesn’t suppor them they just show up as the shortcode. But it seemed like a fun challenge, especially to support animated ones.
Something I had to figure out first though was how I was going to render text. I was using SwiftUI.Text embeded in a UIKit view. But if I needed to use a UIKit view, that would make a big difference in how I implimented this.
UILabel doesn’t support links. You can use a tap gesture recognizer and compute the link location but I’ve done it before and it’s hard to get right. I briefly considered rendering my own label using TextKit, which would give me a lot of freedom. But at the end of the day, Text just did everything I wanted. The downside though is that the bridge between UIKit and SwiftUI can be a bit buggy. But I think I have that worked out 🤞🏻.
With that figured out, I just needed to get tiny images into Text. A little known feature of Text is that it has pretty simple and easy Image embeds out of the box. One of my favorite tricks is to use something like
Text("\(Image(systemSymbol: .arrow2Squarepath) \(name) boosted"). Then if the text wraps it isn’t indented like it would be with an HStack.
In order to support partially loaded and animated images, I first parsed my AttributedStrings into parts, and then each time an image updates, stitch it back together with the current images.
Getting sizing right was a little tricky. On the web Mastodon shows custom emoji fairly large (20pt vs the text’s 15pt), but doing that pushed the alignment of the surrounding text off. It would be nice to use UIImage’s alignmentRect to give the image a vertical overflow, but unfortunately SwiftUI ignores that, at least inside of Text. I was able to instead use
Text.baselineOffset() to adjust it’s position so it was centered with the text. I still needed to limit their size more than I would have liked, but the end result looks good.
In the middle of working on emoji, there was a bug that popped up, that I had seen before, but was unable to reproduce. When I refreshed on status, the media and boost label would disappear on another, and then come back if I refreshed that post. And this was happening every time, even after relaunching the app. Some bugs you just have to drop everything when you catch them in the wild.
After poking at it a big, I found that when I was updating those views to display onscreen, I would set
isHidden = false so they would appear in their UIStackView. But UIStackView would then just set it right back to true, keeping them from being displayed. Maybe this was because I was updating them when they were offscreen. Maybe it was something to do with animation. Maybe I could have found a workaround. But this was so incredibly frustrating. UIStackView is not a new API. It should “just work”.
So I ripped out UIStackView and setup constraints manually. More code, more boilerplate, but at least it does what I say now. And I have more control over the animations going forward.
I have found that if you update a custom emoji, it will reuse the same URL. ↩