Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bugfix FXIOS-9830 Do not duplicate favicon URL and image requests on UITableViewCell reload [Favicon Refactor] #21587

Merged
merged 25 commits into from
Aug 29, 2024
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
773d10a
Update suggested top sites model and hard code favicon URLs.
ih-codes Jul 26, 2024
c1fb213
Remove code related to loading bundled favicons.
ih-codes Jul 26, 2024
ed573a8
Delete TopSites bundled favicon assets (1.2 MB save).
ih-codes Jul 26, 2024
8647976
Use the faviconURL for the pinned Google site and DefaultSuggestedSit…
ih-codes Jul 29, 2024
dbbb81d
Make the Google pinned site have a hardcoded favicon URL to reduce la…
ih-codes Jul 29, 2024
726c0ec
Refactor this try/catch to wrap the code it's related to, otherwise i…
ih-codes Jul 30, 2024
902f5aa
Refactor as bundled asset step no longer needed in ImageHandler.
ih-codes Jul 30, 2024
406533a
Refactoring GoogleTopSiteManager a bit.
ih-codes Jul 30, 2024
2d17bd8
Delete tests and mocks using the removed DefaultBundleImageFetcher. U…
ih-codes Jul 30, 2024
b2b0699
Add some documentation for URL and Image requests paths.
ih-codes Jul 30, 2024
e467a27
Documentation update.
ih-codes Jul 30, 2024
bb173eb
Don't cache empty favicon URLs.
ih-codes Aug 1, 2024
00a714d
swiftlint fixes
ih-codes Aug 6, 2024
02056a7
Swiftlint fix.
ih-codes Aug 21, 2024
967ab07
Typo fix.
ih-codes Aug 21, 2024
308bc87
Swiftlint fix 2.
ih-codes Aug 21, 2024
f9ceed9
Revised fix for FXIOS-9427. Introduced dict for storing and awaiting …
ih-codes Aug 22, 2024
2b0baae
swiflint fixes. Added documentation.
ih-codes Aug 22, 2024
6a69dd9
Revert SiteImageHandler from actor to class. Add @MainActor annotatio…
ih-codes Aug 23, 2024
027dfe0
Add unit test duplicating the multiple calls to getFaviconImage for t…
ih-codes Aug 23, 2024
9aeadad
Add weak self to avoid retention on reallocations. Small test improve…
ih-codes Aug 23, 2024
212eb84
Cleanup.
ih-codes Aug 23, 2024
d5043ee
swiftlint fixes.
ih-codes Aug 26, 2024
872cb5b
Swiftlint fix 2.
ih-codes Aug 26, 2024
f70af63
Merge remote-tracking branch 'mozilla/main' into ih/FXIOS-9830-fix-du…
ih-codes Aug 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ struct DefaultFaviconURLHandler: FaviconURLHandler {
self.urlCache = urlCache
}

/// Attempts to get the favicon URL associated with this site. First checks the URL cache. If the URL can't be obtained
/// from the cache, then a network request is made to hopefully scrape the favicon URL from a webpage's metadata.
/// **Note**: This is a slow call when the URL is not cached.
func getFaviconURL(site: SiteImageModel) async throws -> SiteImageModel {
// Don't fetch favicon URL if we don't have a URL or domain for it
guard let siteURL = site.siteURL else {
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@ import UIKit

protocol ImageHandler {
/// The ImageHandler will fetch the favicon with the following precedence:
/// 1. Tries to fetch from the bundle.
/// 2. Tries to fetch from the cache.
/// 3. Tries to fetch from the favicon fetcher (from the web) if there's a URL.
/// 1. Tries to fetch from the cache.
/// 2. Tries to fetch from the favicon fetcher (from the web) if there's a URL.
/// If there's no URL it fallbacks to the letter favicon.
/// 4. When all fails it returns the letter favicon.
/// 3. When all fails it returns the letter favicon.
///
/// Any time the favicon is fetched, it will be cache for future usage.
///
Expand All @@ -37,18 +36,15 @@ protocol ImageHandler {
}

class DefaultImageHandler: ImageHandler {
private let bundleImageFetcher: BundleImageFetcher
private let imageCache: SiteImageCache
private let faviconFetcher: FaviconFetcher
private let letterImageGenerator: LetterImageGenerator
private let heroImageFetcher: HeroImageFetcher

init(bundleImageFetcher: BundleImageFetcher = DefaultBundleImageFetcher(),
imageCache: SiteImageCache = DefaultSiteImageCache(),
init(imageCache: SiteImageCache = DefaultSiteImageCache(),
faviconFetcher: FaviconFetcher = DefaultFaviconFetcher(),
letterImageGenerator: LetterImageGenerator = DefaultLetterImageGenerator(),
heroImageFetcher: HeroImageFetcher = DefaultHeroImageFetcher()) {
self.bundleImageFetcher = bundleImageFetcher
self.imageCache = imageCache
self.faviconFetcher = faviconFetcher
self.letterImageGenerator = letterImageGenerator
Expand All @@ -57,9 +53,9 @@ class DefaultImageHandler: ImageHandler {

func fetchFavicon(site: SiteImageModel) async -> UIImage {
do {
return try bundleImageFetcher.getImageFromBundle(domain: site.domain)
return try await imageCache.getImageFromCache(cacheKey: site.cacheKey, type: site.expectedImageType)
} catch {
return await fetchFaviconFromCache(site: site)
return await fetchFaviconFromFetcher(site: site)
}
}

Expand All @@ -79,14 +75,6 @@ class DefaultImageHandler: ImageHandler {

// MARK: Private

private func fetchFaviconFromCache(site: SiteImageModel) async -> UIImage {
do {
return try await imageCache.getImageFromCache(cacheKey: site.cacheKey, type: site.expectedImageType)
} catch {
return await fetchFaviconFromFetcher(site: site)
}
}

private func fetchFaviconFromFetcher(site: SiteImageModel) async -> UIImage {
do {
guard let url = site.faviconURL else {
Expand Down
72 changes: 41 additions & 31 deletions BrowserKit/Sources/SiteImageView/SiteImageHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,11 @@ public class DefaultSiteImageHandler: SiteImageHandler {
private let urlHandler: FaviconURLHandler
private let imageHandler: ImageHandler

private let serialQueue = DispatchQueue(label: "com.mozilla.DefaultSiteImageHandler")
private var _currentInFlightRequest: String?
private var currentInFlightRequest: String? {
get { return serialQueue.sync { _currentInFlightRequest } }
set { serialQueue.sync { _currentInFlightRequest = newValue } }
}
/// Right now, multiple `SiteImageView`s each have their own `DefaultSiteImageHandler`. Ideally they'd all share a
/// reference to the same `DefaultSiteImageHandler` so we could properly queue and throttle requests to get favicon
/// URLs, download images, etc. Since that's a large architectural change, for now lets use a static queue so we can
/// prevent too many duplicate calls to remotely fetching URLs and images. (FXIOS-9830, revised FXIOS-9427 bugfix)
private(set) static var requestQueue: [String: Task<UIImage, Never>] = [:]
Copy link
Collaborator

@mattreaganmozilla mattreaganmozilla Aug 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think my only concern with this is the introduction of this global state; it's somewhat obscured that all of these instances will be potentially checking against the same shared request queue, even if they are initialized with different url handlers or image handlers. Right now I don't think that matters much in practice, and it's hard to imagine how it would realistically cause problems but it might be something nice for us to revisit at some point.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I totally understand. Ideally this would all be refactored to be using a single instance of DefaultSiteImageHandler passed in to each cell, but this would entail a big refactor...

I'm ok with leaving the current solution in as well, as long as we can get my new unit test to pass reliably for it. Since that (I believe) mimics the current state of affairs with multiple instances of DefaultSiteImageHandler being created for a single cell/site URL.

I'll experiment with it a bit this afternoon.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We went back and forth on this a bunch when originally building it if this should be a singleton or not. In the end we opted to go with the simpler solution and live with the fact there would occasionally be the odd unnecessary request but obviously that has turned out to be a worse problem than we anticipated.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not too worried about the global state from an app stability perspective because it's private and it's within a pretty contained submodule but it's a little concerning from a test perspective because of the possibility of one test colliding with another. It's not a major concern tho and seems like a reasonable solution that limits the scope of the change. Just worth keeping an eye on if we start getting flaky tests in this area in the future we might want to revisit.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @OrlaM! That makes sense. This is definitely something to keep an eye on in the future... I was browsing Sentry to see if I could find any EXC_BAD_ACCESS type crashes related to favicons and saw this one. Made me wonder if it was something related to this recreation of the SiteImageHandler.


public static func factory() -> DefaultSiteImageHandler {
return DefaultSiteImageHandler()
Expand All @@ -46,15 +45,15 @@ public class DefaultSiteImageHandler: SiteImageHandler {
faviconURL: imageModel.faviconURL,
type: imageModel.expectedImageType)

do {
switch site.expectedImageType {
case .heroImage:
switch site.expectedImageType {
case .heroImage:
do {
imageModel.heroImage = try await getHeroImage(imageModel: imageModel)
case .favicon:
} catch {
// If hero image fails, we return a favicon image
imageModel.faviconImage = await getFaviconImage(imageModel: imageModel)
}
} catch {
// If hero image fails, we return a favicon image
case .favicon:
imageModel.faviconImage = await getFaviconImage(imageModel: imageModel)
}

Expand Down Expand Up @@ -107,29 +106,40 @@ public class DefaultSiteImageHandler: SiteImageHandler {
}
}

// Note: Must be synchronized on an actor to avoid crashes (managing the activeRequest queue).
@MainActor
private func getFaviconImage(imageModel: SiteImageModel) async -> UIImage {
do {
while let currentSiteRequest = currentInFlightRequest,
imageModel.siteURLString == currentSiteRequest {
// We are already processing a favicon request for this site
// Sleep this task until the previous request is completed
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
}
// Check if we're already awaiting a request to get the favicon image for this cacheKey.
// If so, simply await that request rather than begin a new one.
let requestKey = imageModel.cacheKey
if let activeRequest = DefaultSiteImageHandler.requestQueue[requestKey] {
return await activeRequest.value
}

currentInFlightRequest = imageModel.siteURLString
var faviconURLImageModel = imageModel
if faviconURLImageModel.faviconURL == nil {
// Try to fetch the favicon URL
faviconURLImageModel = try await urlHandler.getFaviconURL(site: imageModel)
let requestHandle = Task {
var faviconImageModel = imageModel

do {
if faviconImageModel.faviconURL == nil {
// Try to obtain the favicon URL if needed (ideally from cache, otherwise scrape the webpage)
faviconImageModel = try await urlHandler.getFaviconURL(site: imageModel)
}

// Try to load the favicon image from the cache, or make a request to the favicon URL
// if it's not in the cache
let icon = await imageHandler.fetchFavicon(site: faviconImageModel)
return icon
} catch {
// If no favicon URL, generate favicon without it
let letter = await imageHandler.fetchFavicon(site: imageModel)
return letter
}
let icon = await imageHandler.fetchFavicon(site: faviconURLImageModel)
currentInFlightRequest = nil
return icon
} catch {
// If no favicon URL, generate favicon without it
currentInFlightRequest = nil
return await imageHandler.fetchFavicon(site: imageModel)
}

DefaultSiteImageHandler.requestQueue[requestKey] = requestHandle
let image = await requestHandle.value
DefaultSiteImageHandler.requestQueue[requestKey] = nil
return image
}

private func generateDomainURL(siteURL: URL) -> ImageDomain {
Expand Down
6 changes: 3 additions & 3 deletions BrowserKit/Sources/SiteImageView/SiteImageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ extension SiteImageView {
}

func updateImage(site: SiteImageModel) {
Task {
let imageModel = await imageFetcher.getImage(site: site)
Task { [weak self] in
let imageModel = await self?.imageFetcher.getImage(site: site)

DispatchQueue.main.async { [weak self] in
guard let self, uniqueID == imageModel.id else { return }
guard let self, let imageModel, uniqueID == imageModel.id else { return }
setImage(imageModel: imageModel)
}
}
Expand Down
Loading