Compare commits

...

4 Commits

Author SHA1 Message Date
Shadowfacts 8b78a5e7ad Don't parent background managed object contexts to view context
Otherwise, certain operations require the background contexts to
interact with the view context, which can block the main thread from
accessing the view context (potentially causing hitches if the view
context access is in a critical path, like cell fetching).
2022-09-11 23:00:51 -04:00
Shadowfacts 66c17006d1 Fix poll votes displaying random number
i have no idea where the number was coming from
2022-09-11 22:35:09 -04:00
Shadowfacts 8a911f238b Fix emojis getting set without setting emoji identifier 2022-09-11 22:20:46 -04:00
Shadowfacts 77c44c323f Use os_unfair_lock for MultiThreadDictionary instead of DispatchQueue 2022-09-11 22:20:46 -04:00
11 changed files with 96 additions and 70 deletions

View File

@ -24,7 +24,7 @@ class ImageCache {
private let cache: ImageDataCache private let cache: ImageDataCache
private let desiredPixelSize: CGSize? private let desiredPixelSize: CGSize?
private var groups = MultiThreadDictionary<URL, RequestGroup>(name: "ImageCache request groups") private var groups = MultiThreadDictionary<URL, RequestGroup>()
private var backgroundQueue = DispatchQueue(label: "ImageCache completion queue", qos: .default) private var backgroundQueue = DispatchQueue(label: "ImageCache completion queue", qos: .default)
@ -96,7 +96,7 @@ class ImageCache {
if let data = data { if let data = data {
try? self.cache.set(url.absoluteString, data: data, image: image) try? self.cache.set(url.absoluteString, data: data, image: image)
} }
self.groups.removeValueWithoutReturning(forKey: url) _ = self.groups.removeValue(forKey: url)
} }
groups[url] = group groups[url] = group
return group return group

View File

@ -20,13 +20,15 @@ class MastodonCachePersistentStore: NSPersistentContainer {
private(set) lazy var backgroundContext: NSManagedObjectContext = { private(set) lazy var backgroundContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.parent = self.viewContext context.persistentStoreCoordinator = self.persistentStoreCoordinator
context.automaticallyMergesChangesFromParent = true
return context return context
}() }()
private(set) lazy var prefetchBackgroundContext: NSManagedObjectContext = { private(set) lazy var prefetchBackgroundContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.parent = self.viewContext context.persistentStoreCoordinator = self.persistentStoreCoordinator
context.automaticallyMergesChangesFromParent = true
return context return context
}() }()
@ -51,6 +53,8 @@ class MastodonCachePersistentStore: NSPersistentContainer {
} }
} }
viewContext.automaticallyMergesChangesFromParent = true
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext) NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
} }

View File

@ -7,52 +7,79 @@
// //
import Foundation import Foundation
import os
class MultiThreadDictionary<Key: Hashable, Value> { // once we target iOS 16, replace uses of this with OSAllocatedUnfairLock<[Key: Value]>
private let name: String // to make the lock semantics more clear
private var dict = [Key: Value]() @available(iOS, obsoleted: 16.0)
private let queue: DispatchQueue class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
private let lock: any Lock<[Key: Value]>
init(name: String) { init() {
self.name = name if #available(iOS 16.0, *) {
self.queue = DispatchQueue(label: "MultiThreadDictionary (\(name)) Coordinator", attributes: .concurrent) self.lock = OSAllocatedUnfairLock(initialState: [:])
} else {
self.lock = UnfairLock(initialState: [:])
}
} }
subscript(key: Key) -> Value? { subscript(key: Key) -> Value? {
get { get {
var result: Value? = nil return lock.withLock { dict in
queue.sync { dict[key]
result = dict[key]
} }
return result
} }
set(value) { set(value) {
queue.async(flags: .barrier) { lock.withLock { dict in
self.dict[key] = value dict[key] = value
} }
} }
} }
func removeValueWithoutReturning(forKey key: Key) {
queue.async(flags: .barrier) {
self.dict.removeValue(forKey: key)
}
}
/// If the result of this function is unused, it is preferable to use `removeValueWithoutReturning` as it executes asynchronously and doesn't block the calling thread. /// If the result of this function is unused, it is preferable to use `removeValueWithoutReturning` as it executes asynchronously and doesn't block the calling thread.
func removeValue(forKey key: Key) -> Value? { func removeValue(forKey key: Key) -> Value? {
var value: Value? = nil return lock.withLock { dict in
queue.sync(flags: .barrier) { dict.removeValue(forKey: key)
value = dict.removeValue(forKey: key)
} }
return value
} }
func contains(key: Key) -> Bool { func contains(key: Key) -> Bool {
var value: Bool! return lock.withLock { dict in
queue.sync { dict.keys.contains(key)
value = dict.keys.contains(key)
} }
return value }
func withLock<R>(_ body: @Sendable (inout [Key: Value]) throws -> R) rethrows -> R where R: Sendable {
return try lock.withLock(body)
}
}
// TODO: replace this only with OSAllocatedUnfairLock
@available(iOS, obsoleted: 16.0)
fileprivate protocol Lock<State> {
associatedtype State
func withLock<R>(_ body: @Sendable (inout State) throws -> R) rethrows -> R where R: Sendable
}
@available(iOS 16.0, *)
extension OSAllocatedUnfairLock: Lock {
}
// from http://www.russbishop.net/the-law
fileprivate class UnfairLock<State>: Lock {
private var lock: UnsafeMutablePointer<os_unfair_lock>
private var state: State
init(initialState: State) {
self.state = initialState
self.lock = .allocate(capacity: 1)
self.lock.initialize(to: os_unfair_lock())
}
deinit {
self.lock.deallocate()
}
func withLock<R>(_ body: (inout State) throws -> R) rethrows -> R where R: Sendable {
os_unfair_lock_lock(lock)
defer { os_unfair_lock_unlock(lock) }
return try body(&state)
} }
} }

View File

@ -52,7 +52,7 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
displayNameLabel.updateForAccountDisplayName(account: account) displayNameLabel.updateForAccountDisplayName(account: account)
noteTextView.setTextFromHtml(account.note) noteTextView.setTextFromHtml(account.note)
noteTextView.setEmojis(account.emojis) noteTextView.setEmojis(account.emojis, identifier: account.id)
avatarImageView.image = nil avatarImageView.image = nil
if let avatar = account.avatar { if let avatar = account.avatar {

View File

@ -36,7 +36,7 @@ struct AccountDisplayNameLabel<Account: AccountProtocol>: View {
let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange) let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange)
guard !matches.isEmpty else { return } guard !matches.isEmpty else { return }
let emojiImages = MultiThreadDictionary<String, Image>(name: "AcccountDisplayNameLabel Emoji Images") let emojiImages = MultiThreadDictionary<String, Image>()
let group = DispatchGroup() let group = DispatchGroup()

View File

@ -24,6 +24,7 @@ extension BaseEmojiLabel {
// blergh // blergh
precondition(Thread.isMainThread) precondition(Thread.isMainThread)
emojiIdentifier = identifier
emojiRequests.forEach { $0.cancel() } emojiRequests.forEach { $0.cancel() }
emojiRequests = [] emojiRequests = []
@ -38,10 +39,7 @@ extension BaseEmojiLabel {
return return
} }
// not using a MultiThreadDictionary so that cached images can be added immediately let emojiImages = MultiThreadDictionary<String, UIImage>()
// without jumping through various queues so that we can use them immediately
// in building either the final string or the string with placeholders
var emojiImages: [String: UIImage] = [:]
var foundEmojis = false var foundEmojis = false
let group = DispatchGroup() let group = DispatchGroup()
@ -71,12 +69,8 @@ extension BaseEmojiLabel {
group.leave() group.leave()
return return
} }
// sync back to the main thread to add the dictionary emojiImages[emoji.shortcode] = transformedImage
// todo: using the main thread for this isn't great group.leave()
DispatchQueue.main.async {
emojiImages[emoji.shortcode] = transformedImage
group.leave()
}
} }
if let request = request { if let request = request {
emojiRequests.append(request) emojiRequests.append(request)
@ -92,21 +86,27 @@ extension BaseEmojiLabel {
func buildStringWithEmojisReplaced(usePlaceholders: Bool) -> NSAttributedString { func buildStringWithEmojisReplaced(usePlaceholders: Bool) -> NSAttributedString {
let mutAttrString = NSMutableAttributedString(attributedString: attributedString) let mutAttrString = NSMutableAttributedString(attributedString: attributedString)
// replaces the emojis starting from the end of the string as to not alter the indices of preceeding emojis // lock once for the entire loop, rather than lock/unlocking for each iteration to do the lookup
for match in matches.reversed() { // OSAllocatedUnfairLock.withLock expects a @Sendable closure, so this warns about captures of non-sendable types (attribute dstrings, text checking results)
let shortcode = (attributedString.string as NSString).substring(with: match.range(at: 1)) // even though the closures is invoked on the same thread that withLock is called, so it's unclear why it needs to be @Sendable (FB11494878)
let attachment: NSTextAttachment // so, just ignore the warnings
emojiImages.withLock { emojiImages in
// replaces the emojis starting from the end of the string as to not alter the indices of preceeding emojis
for match in matches.reversed() {
let shortcode = (attributedString.string as NSString).substring(with: match.range(at: 1))
let attachment: NSTextAttachment
if let emojiImage = emojiImages[shortcode] { if let emojiImage = emojiImages[shortcode] {
attachment = NSTextAttachment(emojiImage: emojiImage, in: self.emojiFont, with: self.emojiTextColor) attachment = NSTextAttachment(emojiImage: emojiImage, in: self.emojiFont, with: self.emojiTextColor)
} else if usePlaceholders { } else if usePlaceholders {
attachment = NSTextAttachment(emojiPlaceholderIn: self.emojiFont) attachment = NSTextAttachment(emojiPlaceholderIn: self.emojiFont)
} else { } else {
continue continue
}
let attachmentStr = NSAttributedString(attachment: attachment)
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
} }
let attachmentStr = NSAttributedString(attachment: attachment)
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
} }
return mutAttrString return mutAttrString

View File

@ -57,8 +57,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
} }
// MARK: - Emojis // MARK: - Emojis
func setEmojis(_ emojis: [Emoji]) { func setEmojis(_ emojis: [Emoji], identifier: String?) {
replaceEmojis(in: attributedText!, emojis: emojis, identifier: emojiIdentifier) { attributedString, didReplaceEmojis in replaceEmojis(in: attributedText!, emojis: emojis, identifier: identifier) { attributedString, didReplaceEmojis in
guard didReplaceEmojis else { guard didReplaceEmojis else {
return return
} }

View File

@ -21,13 +21,9 @@ class EmojiLabel: UILabel, BaseEmojiLabel {
func setEmojis(_ emojis: [Emoji], identifier: String) { func setEmojis(_ emojis: [Emoji], identifier: String) {
guard emojis.count > 0, let attributedText = attributedText else { return } guard emojis.count > 0, let attributedText = attributedText else { return }
self.emojiIdentifier = identifier replaceEmojis(in: attributedText.string, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText, didReplaceEmojis) in
emojiRequests.forEach { $0.cancel() }
emojiRequests = []
hasEmojis = true
replaceEmojis(in: attributedText.string, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText, _) in
guard let self = self, self.emojiIdentifier == identifier else { return } guard let self = self, self.emojiIdentifier == identifier else { return }
self.hasEmojis = didReplaceEmojis
self.attributedText = newAttributedText self.attributedText = newAttributedText
self.setNeedsLayout() self.setNeedsLayout()
self.setNeedsDisplay() self.setNeedsDisplay()

View File

@ -110,7 +110,7 @@ class ProfileHeaderView: UIView {
noteTextView.navigationDelegate = delegate noteTextView.navigationDelegate = delegate
noteTextView.setTextFromHtml(account.note) noteTextView.setTextFromHtml(account.note)
noteTextView.setEmojis(account.emojis) noteTextView.setEmojis(account.emojis, identifier: account.id)
// don't show relationship label for the user's own account // don't show relationship label for the user's own account
if accountID != mastodonController.account?.id { if accountID != mastodonController.account?.id {
@ -148,7 +148,7 @@ class ProfileHeaderView: UIView {
valueTextView.isSelectable = false valueTextView.isSelectable = false
valueTextView.font = .systemFont(ofSize: 17) valueTextView.font = .systemFont(ofSize: 17)
valueTextView.setTextFromHtml(field.value) valueTextView.setTextFromHtml(field.value)
valueTextView.setEmojis(account.emojis) valueTextView.setEmojis(account.emojis, identifier: account.id)
valueTextView.textAlignment = .left valueTextView.textAlignment = .left
valueTextView.awakeFromNib() valueTextView.awakeFromNib()
valueTextView.navigationDelegate = delegate valueTextView.navigationDelegate = delegate

View File

@ -16,9 +16,8 @@ class StatusContentTextView: ContentTextView {
func setTextFrom(status: StatusMO) { func setTextFrom(status: StatusMO) {
statusID = status.id statusID = status.id
emojiIdentifier = status.id
setTextFromHtml(status.content) setTextFromHtml(status.content)
setEmojis(status.emojis) setEmojis(status.emojis, identifier: status.id)
} }
override func getMention(for url: URL, text: String) -> Mention? { override func getMention(for url: URL, text: String) -> Mention? {

View File

@ -5,7 +5,7 @@
<key>poll votes count</key> <key>poll votes count</key>
<dict> <dict>
<key>NSStringLocalizedFormatKey</key> <key>NSStringLocalizedFormatKey</key>
<string>%2$#@votes@</string> <string>%#@votes@</string>
<key>votes</key> <key>votes</key>
<dict> <dict>
<key>NSStringFormatSpecTypeKey</key> <key>NSStringFormatSpecTypeKey</key>