feat: cache placeholder and blurhash more efficiency

This commit is contained in:
CMK 2021-06-16 18:32:48 +08:00
parent c456281e2e
commit a413adc613
10 changed files with 195 additions and 79 deletions

View File

@ -207,6 +207,7 @@
DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */; };
DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */; };
DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */; };
DB297B1B2679FAE200704C90 /* PlaceholderImageCacheService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB297B1A2679FAE200704C90 /* PlaceholderImageCacheService.swift */; };
DB2B3ABC25E37E15007045F9 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB2B3ABE25E37E15007045F9 /* InfoPlist.strings */; };
DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; };
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; };
@ -414,6 +415,7 @@
DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */; };
DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */; };
DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */; };
DBAEDE5C267A058D00D25FF5 /* BlurhashImageCacheService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */; };
DBAFB7352645463500371D5F /* Emojis.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAFB7342645463500371D5F /* Emojis.swift */; };
DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; };
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; };
@ -779,6 +781,8 @@
DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerItem.swift; sourceTree = "<group>"; };
DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = "<group>"; };
DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputViewModel.swift; sourceTree = "<group>"; };
DB297B192679F5EF00704C90 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ActiveLabel.swift; path = ../ActiveLabel.swift; sourceTree = "<group>"; };
DB297B1A2679FAE200704C90 /* PlaceholderImageCacheService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderImageCacheService.swift; sourceTree = "<group>"; };
DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = "<group>"; };
DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = "<group>"; };
@ -984,6 +988,7 @@
DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Mute.swift"; sourceTree = "<group>"; };
DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = "<group>"; };
DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteProfileViewModel.swift; sourceTree = "<group>"; };
DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurhashImageCacheService.swift; sourceTree = "<group>"; };
DBAFB7342645463500371D5F /* Emojis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emojis.swift; sourceTree = "<group>"; };
DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = "<group>"; };
DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = "<group>"; };
@ -1350,6 +1355,8 @@
DB4924E126312AB200E9DB22 /* NotificationService.swift */,
DB6D9F6226357848008423CD /* SettingService.swift */,
DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */,
DB297B1A2679FAE200704C90 /* PlaceholderImageCacheService.swift */,
DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */,
);
path = Service;
sourceTree = "<group>";
@ -1682,6 +1689,7 @@
children = (
DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */,
DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */,
DB297B192679F5EF00704C90 /* ActiveLabel.swift */,
DB3D0FED25BAA42200EAA174 /* MastodonSDK */,
DB427DD425BAA00100D1B89D /* Mastodon */,
DB427DEB25BAA00100D1B89D /* MastodonTests */,
@ -2995,6 +3003,7 @@
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */,
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */,
DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */,
DB297B1B2679FAE200704C90 /* PlaceholderImageCacheService.swift in Sources */,
2D8FCA082637EABB00137F46 /* APIService+FollowRequest.swift in Sources */,
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */,
2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */,
@ -3100,6 +3109,7 @@
2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */,
0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */,
5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */,
DBAEDE5C267A058D00D25FF5 /* BlurhashImageCacheService.swift in Sources */,
2D38F1DF25CD46A400561493 /* HomeTimelineViewController+Provider.swift in Sources */,
DB1D843026566512000346B3 /* KeyboardPreference.swift in Sources */,
2D206B9225F60EA700143C56 /* UIControl.swift in Sources */,

View File

@ -27,12 +27,12 @@
<key>Mastodon.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>13</integer>
<integer>12</integer>
</dict>
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>12</integer>
<integer>16</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@ -84,7 +84,7 @@ extension StatusSection {
case .root:
StatusSection.configureThreadMeta(cell: cell, status: status)
ManagedObjectObserver.observe(object: status.reblog ?? status)
.receive(on: DispatchQueue.main)
.receive(on: RunLoop.main)
.sink { _ in
// do nothing
} receiveValue: { change in
@ -160,9 +160,9 @@ extension StatusSection {
requestUserID: String,
statusItemAttribute: Item.StatusAttribute
) {
// safely cancel the listenser when deleted
// safely cancel the listener when deleted
ManagedObjectObserver.observe(object: status.reblog ?? status)
.receive(on: DispatchQueue.main)
.receive(on: RunLoop.main)
.sink { _ in
// do nothing
} receiveValue: { [weak cell] change in
@ -174,11 +174,10 @@ extension StatusSection {
}
.store(in: &cell.disposeBag)
// set header
StatusSection.configureHeader(cell: cell, status: status)
ManagedObjectObserver.observe(object: status)
.receive(on: DispatchQueue.main)
.receive(on: RunLoop.main)
.sink { _ in
// do nothing
} receiveValue: { [weak cell] change in
@ -221,7 +220,7 @@ extension StatusSection {
cell.statusView.updateVisibility(visibility: visibility)
cell.statusView.revealContentWarningButton.publisher(for: \.isHidden)
.receive(on: DispatchQueue.main)
.receive(on: RunLoop.main)
.sink { [weak cell] isHidden in
cell?.statusView.visibilityImageView.isHidden = !isHidden
}
@ -234,7 +233,7 @@ extension StatusSection {
let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending }
// set image
let mosiacImageViewModel = MosaicImageViewModel(mediaAttachments: mediaAttachments)
let mosaicImageViewModel = MosaicImageViewModel(mediaAttachments: mediaAttachments)
let imageViewMaxSize: CGSize = {
let maxWidth: CGFloat = {
// use timelinePostView width as container width
@ -246,43 +245,33 @@ extension StatusSection {
return containerWidth
}()
let scale: CGFloat = {
switch mosiacImageViewModel.metas.count {
switch mosaicImageViewModel.metas.count {
case 1: return 1.3
default: return 0.7
}
}()
return CGSize(width: maxWidth, height: maxWidth * scale)
}()
let blurhashImageCache = dependency.context.documentStore.blurhashImageCache
let mosaics: [MosaicImageViewContainer.ConfigurableMosaic] = {
if mosiacImageViewModel.metas.count == 1 {
let meta = mosiacImageViewModel.metas[0]
if mosaicImageViewModel.metas.count == 1 {
let meta = mosaicImageViewModel.metas[0]
let mosaic = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize)
return [mosaic]
} else {
let mosaics = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height)
let mosaics = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosaicImageViewModel.metas.count, maxHeight: imageViewMaxSize.height)
return mosaics
}
}()
for (i, mosiac) in mosaics.enumerated() {
let (imageView, blurhashOverlayImageView) = mosiac
let meta = mosiacImageViewModel.metas[i]
let blurhashImageDataKey = meta.url.absoluteString as NSString
if let blurhashImageData = blurhashImageCache.object(forKey: meta.url.absoluteString as NSString),
let image = UIImage(data: blurhashImageData as Data) {
blurhashOverlayImageView.image = image
} else {
meta.blurhashImagePublisher()
.receive(on: DispatchQueue.main)
.sink { [weak blurhashImageCache] image in
guard let blurhashImageCache = blurhashImageCache else { return }
blurhashOverlayImageView.image = image
image?.pngData().flatMap {
blurhashImageCache.setObject($0 as NSData, forKey: blurhashImageDataKey)
}
}
.store(in: &cell.disposeBag)
}
for (i, mosaic) in mosaics.enumerated() {
let (imageView, blurhashOverlayImageView) = mosaic
let meta = mosaicImageViewModel.metas[i]
meta.blurhashImagePublisher()
.receive(on: RunLoop.main)
.sink { image in
blurhashOverlayImageView.image = image
}
.store(in: &cell.disposeBag)
imageView.af.setImage(
withURL: meta.url,
placeholderImage: UIImage.placeholder(color: .systemFill),
@ -300,7 +289,7 @@ extension StatusSection {
statusItemAttribute.isImageLoaded,
statusItemAttribute.isRevealing
)
.receive(on: DispatchQueue.main)
.receive(on: RunLoop.main)
.sink { [weak cell] isImageLoaded, isMediaRevealing in
guard let cell = cell else { return }
guard isImageLoaded else {
@ -322,7 +311,7 @@ extension StatusSection {
}
.store(in: &cell.disposeBag)
}
cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty
cell.statusView.statusMosaicImageViewContainer.isHidden = mosaicImageViewModel.metas.isEmpty
// set audio
if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first {
@ -408,7 +397,7 @@ extension StatusSection {
)
// observe model change
ManagedObjectObserver.observe(object: status)
.receive(on: DispatchQueue.main)
.receive(on: RunLoop.main)
.sink { _ in
// do nothing
} receiveValue: { [weak dependency, weak cell] change in
@ -479,7 +468,7 @@ extension StatusSection {
// observe model change
ManagedObjectObserver.observe(object: status.reblog ?? status)
.receive(on: DispatchQueue.main)
.receive(on: RunLoop.main)
.sink { _ in
// do nothing
} receiveValue: { [weak dependency, weak cell] change in
@ -700,7 +689,7 @@ extension StatusSection {
ManagedObjectObserver.observe(object: status.authorForUserProvider)
.assertNoFailure()
)
.receive(on: DispatchQueue.main)
.receive(on: RunLoop.main)
.sink { [weak dependency, weak cell] _, change in
guard let cell = cell else { return }
guard let dependency = dependency else { return }

View File

@ -63,6 +63,7 @@ extension ActiveLabel {
extension ActiveLabel {
/// status content
func configure(content: String, emojiDict: MastodonStatusContent.EmojiDict) {
attributedText = nil
activeEntities.removeAll()
if let parseResult = try? MastodonStatusContent.parse(content: content, emojiDict: emojiDict) {
@ -144,10 +145,10 @@ extension ActiveLabel {
element.accessibilityLanguage = accessibilityLanguage
elements.append(element)
for eneity in activeEntities {
guard let element = eneity.accessibilityElement(in: self) else { continue }
for entity in activeEntities {
guard let element = entity.accessibilityElement(in: self) else { continue }
var glyphRange = NSRange()
layoutManager.characterRange(forGlyphRange: eneity.range, actualGlyphRange: &glyphRange)
layoutManager.characterRange(forGlyphRange: entity.range, actualGlyphRange: &glyphRange)
let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
element.accessibilityFrame = self.convert(rect, to: nil)
element.accessibilityContainer = self

View File

@ -22,14 +22,14 @@ extension AvatarConfigurableView {
public func configure(with configuration: AvatarConfigurableViewConfiguration) {
let placeholderImage: UIImage = {
let placeholderImage = configuration.placeholderImage ?? UIImage.placeholder(size: Self.configurableAvatarImageSize, color: .systemFill)
if Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 {
return placeholderImage
.af.imageAspectScaled(toFill: Self.configurableAvatarImageSize)
.af.imageRounded(withCornerRadius: Self.configurableAvatarImageCornerRadius, divideRadiusByImageScale: false)
} else {
return placeholderImage.af.imageRoundedIntoCircle()
guard let placeholderImage = configuration.placeholderImage else {
return AppContext.shared.placeholderImageCacheService.image(
color: .systemFill,
size: Self.configurableAvatarImageSize,
cornerRadius: Self.configurableAvatarImageCornerRadius
)
}
return placeholderImage
}()
// cancel previous task

View File

@ -45,40 +45,12 @@ struct MosaicMeta {
let size: CGSize
let blurhash: String?
let altText: String?
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.MosaicMeta.working-queue", qos: .userInitiated, attributes: .concurrent)
func blurhashImagePublisher() -> AnyPublisher<UIImage?, Never> {
return Future { promise in
workingQueue.async {
let image = self.blurhashImage()
promise(.success(image))
}
}
.eraseToAnyPublisher()
}
func blurhashImage() -> UIImage? {
guard let blurhash = blurhash else {
return nil
return Just(nil).eraseToAnyPublisher()
}
let imageSize: CGSize = {
let aspectRadio = size.width / size.height
if size.width > size.height {
let width: CGFloat = MosaicMeta.edgeMaxLength
let height = width / aspectRadio
return CGSize(width: width, height: height)
} else {
let height: CGFloat = MosaicMeta.edgeMaxLength
let width = height * aspectRadio
return CGSize(width: width, height: height)
}
}()
let image = UIImage(blurHash: blurhash, size: imageSize)
return image
return AppContext.shared.blurhashImageCacheService.image(blurhash: blurhash, size: size, url: url)
}
}

View File

@ -0,0 +1,82 @@
//
// BlurhashImageCacheService.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-6-16.
//
import UIKit
import Combine
final class BlurhashImageCacheService {
let cache = NSCache<Key, UIImage>()
let workingQueue = DispatchQueue(label: "org.joinmastodon.app.BlurhashImageCacheService.working-queue", qos: .userInitiated, attributes: .concurrent)
func image(blurhash: String, size: CGSize, url: URL) -> AnyPublisher<UIImage?, Never> {
return Future { promise in
self.workingQueue.async {
let key = Key(blurhash: blurhash, size: size, url: url)
guard let image = self.cache.object(forKey: key) else {
if let image = BlurhashImageCacheService.blurhashImage(blurhash: blurhash, size: size, url: url) {
self.cache.setObject(image, forKey: key)
promise(.success(image))
} else {
promise(.success(nil))
}
return
}
promise(.success(image))
}
}
.eraseToAnyPublisher()
}
static func blurhashImage(blurhash: String, size: CGSize, url: URL) -> UIImage? {
let imageSize: CGSize = {
let aspectRadio = size.width / size.height
if size.width > size.height {
let width: CGFloat = MosaicMeta.edgeMaxLength
let height = width / aspectRadio
return CGSize(width: width, height: height)
} else {
let height: CGFloat = MosaicMeta.edgeMaxLength
let width = height * aspectRadio
return CGSize(width: width, height: height)
}
}()
let image = UIImage(blurHash: blurhash, size: imageSize)
return image
}
}
extension BlurhashImageCacheService {
class Key: Hashable {
static func == (lhs: BlurhashImageCacheService.Key, rhs: BlurhashImageCacheService.Key) -> Bool {
return lhs.blurhash == rhs.blurhash
&& lhs.size == rhs.size
&& lhs.url == rhs.url
}
let blurhash: String
let size: CGSize
let url: URL
init(blurhash: String, size: CGSize, url: URL) {
self.blurhash = blurhash
self.size = size
self.url = url
}
func hash(into hasher: inout Hasher) {
hasher.combine(blurhash)
hasher.combine(size.width)
hasher.combine(size.height)
hasher.combine(url)
}
}
}

View File

@ -0,0 +1,60 @@
//
// PlaceholderImageCacheService.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-6-16.
//
import UIKit
import AlamofireImage
final class PlaceholderImageCacheService {
let cache = NSCache<Key, UIImage>()
func image(color: UIColor, size: CGSize, cornerRadius: CGFloat = 0) -> UIImage {
let key = Key(color: color, size: size, cornerRadius: cornerRadius)
guard let image = cache.object(forKey: key) else {
var image = UIImage.placeholder(size: size, color: color)
if cornerRadius < size.width * 0.5 {
image = image
.af.imageAspectScaled(toFill: size)
.af.imageRounded(withCornerRadius: cornerRadius, divideRadiusByImageScale: false)
} else {
image = image.af.imageRoundedIntoCircle()
}
cache.setObject(image, forKey: key)
return image
}
return image
}
}
extension PlaceholderImageCacheService {
class Key: Hashable {
let color: UIColor
let size: CGSize
let cornerRadius: CGFloat
init(color: UIColor, size: CGSize, cornerRadius: CGFloat) {
self.color = color
self.size = size
self.cornerRadius = cornerRadius
}
static func == (lhs: PlaceholderImageCacheService.Key, rhs: PlaceholderImageCacheService.Key) -> Bool {
return lhs.color == rhs.color
&& lhs.size == rhs.size
&& lhs.cornerRadius == rhs.cornerRadius
}
func hash(into hasher: inout Hasher) {
hasher.combine(color)
hasher.combine(size.width)
hasher.combine(size.height)
hasher.combine(cornerRadius)
}
}
}

View File

@ -35,6 +35,9 @@ class AppContext: ObservableObject {
let blockDomainService: BlockDomainService
let photoLibraryService = PhotoLibraryService()
let placeholderImageCacheService = PlaceholderImageCacheService()
let blurhashImageCacheService = BlurhashImageCacheService()
let documentStore: DocumentStore
private var documentStoreSubscription: AnyCancellable!

View File

@ -10,7 +10,6 @@ import Combine
import MastodonSDK
class DocumentStore: ObservableObject {
let blurhashImageCache = NSCache<NSString, NSData>()
let appStartUpTimestamp = Date()
var defaultRevealStatusDict: [Mastodon.Entity.Status.ID: Bool] = [:]
}