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 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)
@ -96,7 +96,7 @@ class ImageCache {
if let data = data {
try? self.cache.set(url.absoluteString, data: data, image: image)
}
self.groups.removeValueWithoutReturning(forKey: url)
_ = self.groups.removeValue(forKey: url)
}
groups[url] = group
return group

View File

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

View File

@ -7,52 +7,79 @@
//
import Foundation
import os
class MultiThreadDictionary<Key: Hashable, Value> {
private let name: String
private var dict = [Key: Value]()
private let queue: DispatchQueue
// once we target iOS 16, replace uses of this with OSAllocatedUnfairLock<[Key: Value]>
// to make the lock semantics more clear
@available(iOS, obsoleted: 16.0)
class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
private let lock: any Lock<[Key: Value]>
init(name: String) {
self.name = name
self.queue = DispatchQueue(label: "MultiThreadDictionary (\(name)) Coordinator", attributes: .concurrent)
init() {
if #available(iOS 16.0, *) {
self.lock = OSAllocatedUnfairLock(initialState: [:])
} else {
self.lock = UnfairLock(initialState: [:])
}
}
subscript(key: Key) -> Value? {
get {
var result: Value? = nil
queue.sync {
result = dict[key]
return lock.withLock { dict in
dict[key]
}
return result
}
set(value) {
queue.async(flags: .barrier) {
self.dict[key] = value
lock.withLock { dict in
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.
func removeValue(forKey key: Key) -> Value? {
var value: Value? = nil
queue.sync(flags: .barrier) {
value = dict.removeValue(forKey: key)
return lock.withLock { dict in
dict.removeValue(forKey: key)
}
return value
}
func contains(key: Key) -> Bool {
var value: Bool!
queue.sync {
value = dict.keys.contains(key)
return lock.withLock { dict in
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)
noteTextView.setTextFromHtml(account.note)
noteTextView.setEmojis(account.emojis)
noteTextView.setEmojis(account.emojis, identifier: account.id)
avatarImageView.image = nil
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)
guard !matches.isEmpty else { return }
let emojiImages = MultiThreadDictionary<String, Image>(name: "AcccountDisplayNameLabel Emoji Images")
let emojiImages = MultiThreadDictionary<String, Image>()
let group = DispatchGroup()

View File

@ -24,6 +24,7 @@ extension BaseEmojiLabel {
// blergh
precondition(Thread.isMainThread)
emojiIdentifier = identifier
emojiRequests.forEach { $0.cancel() }
emojiRequests = []
@ -38,10 +39,7 @@ extension BaseEmojiLabel {
return
}
// not using a MultiThreadDictionary so that cached images can be added immediately
// 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] = [:]
let emojiImages = MultiThreadDictionary<String, UIImage>()
var foundEmojis = false
let group = DispatchGroup()
@ -71,12 +69,8 @@ extension BaseEmojiLabel {
group.leave()
return
}
// sync back to the main thread to add the dictionary
// todo: using the main thread for this isn't great
DispatchQueue.main.async {
emojiImages[emoji.shortcode] = transformedImage
group.leave()
}
emojiImages[emoji.shortcode] = transformedImage
group.leave()
}
if let request = request {
emojiRequests.append(request)
@ -92,21 +86,27 @@ extension BaseEmojiLabel {
func buildStringWithEmojisReplaced(usePlaceholders: Bool) -> NSAttributedString {
let mutAttrString = NSMutableAttributedString(attributedString: attributedString)
// 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
// lock once for the entire loop, rather than lock/unlocking for each iteration to do the lookup
// OSAllocatedUnfairLock.withLock expects a @Sendable closure, so this warns about captures of non-sendable types (attribute dstrings, text checking results)
// 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)
// 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] {
attachment = NSTextAttachment(emojiImage: emojiImage, in: self.emojiFont, with: self.emojiTextColor)
} else if usePlaceholders {
attachment = NSTextAttachment(emojiPlaceholderIn: self.emojiFont)
} else {
continue
if let emojiImage = emojiImages[shortcode] {
attachment = NSTextAttachment(emojiImage: emojiImage, in: self.emojiFont, with: self.emojiTextColor)
} else if usePlaceholders {
attachment = NSTextAttachment(emojiPlaceholderIn: self.emojiFont)
} else {
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

View File

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

View File

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

View File

@ -110,7 +110,7 @@ class ProfileHeaderView: UIView {
noteTextView.navigationDelegate = delegate
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
if accountID != mastodonController.account?.id {
@ -148,7 +148,7 @@ class ProfileHeaderView: UIView {
valueTextView.isSelectable = false
valueTextView.font = .systemFont(ofSize: 17)
valueTextView.setTextFromHtml(field.value)
valueTextView.setEmojis(account.emojis)
valueTextView.setEmojis(account.emojis, identifier: account.id)
valueTextView.textAlignment = .left
valueTextView.awakeFromNib()
valueTextView.navigationDelegate = delegate

View File

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

View File

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