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

Pages List performance improvements #22070

Merged
merged 8 commits into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* [*] [internal] Fix an issue with scheduling of posts not working on iOS 17 with Xcode 15 [#22012]
* [*] [internal] Remove SDWebImage dependency from the app and improve cache cost calculation for GIFs [#21285]
* [*] Stats: Fix an issue where sites for clicked URLs do not open [#22061]
* [*] Improve pages list performance when there are hundreds of pages in the site [#22070]
* [*] [internal] Make Reader web views inspectable on iOS 16.4 and higher [#22077]

23.7
Expand Down
4 changes: 2 additions & 2 deletions WordPress/Classes/Services/PostRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ extension PostRepository {
/// - statuses: Only fetch pages whose status is included in the given statues.
/// - blogID: Object ID of the site.
/// - Returns: A `Task` instance representing the fetching. The fetch pages API requests will stop if the task is cancelled.
func fetchAllPages(statuses: [BasePost.Status], in blogID: TaggedManagedObjectID<Blog>) -> Task<[TaggedManagedObjectID<Page>], Swift.Error> {
func fetchAllPages(statuses: [BasePost.Status], authorUserID: NSNumber? = nil, in blogID: TaggedManagedObjectID<Blog>) -> Task<[TaggedManagedObjectID<Page>], Swift.Error> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nitpick: The Swift API Guidelines recommend to locate parameters with default values at the end of the list:

Prefer to locate parameters with defaults toward the end of the parameter list. Parameters without defaults are usually more essential to the semantics of a method, and provide a stable initial pattern of use where methods are invoked.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch. I used default parameter value quite freely in a few similar functions in this class. I want to keep the blog parameter as the last parameter (or the first), and rest of the parameters (which act as post/pages filters) don't really have an sensible order. But these functions are added one by one across many PRs, it's probably worth looking into these function holistically later.

Copy link
Contributor

Choose a reason for hiding this comment

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

Sounds good

Task {
let pageSize = 100
var allPages = [TaggedManagedObjectID<Page>]()
Expand All @@ -401,7 +401,7 @@ extension PostRepository {
let current = try await fetch(
type: Page.self,
statuses: statuses,
authorUserID: nil,
authorUserID: authorUserID,
range: pageRange,
deleteOtherLocalPosts: false,
in: blogID
Expand Down
59 changes: 35 additions & 24 deletions WordPress/Classes/Utility/PageTree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,35 @@ final class PageTree {

// A node in a tree, which of course is also a tree itself.
private class TreeNode {
let page: Page
struct PageData {
var postID: NSNumber?
var parentID: NSNumber?
}
Comment on lines +5 to +8
Copy link
Contributor

Choose a reason for hiding this comment

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

Always like a container type for information like this one.

Do the properties need to be mutable?

Copy link
Contributor Author

@crazytonyli crazytonyli Nov 27, 2023

Choose a reason for hiding this comment

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

Maybe no? I used var out of habit without any extra thoughts. However, it's used as a constant though: let pageData: PageData.

let pageID: TaggedManagedObjectID<Page>
let pageData: PageData
var children = [TreeNode]()
var parentNode: TreeNode?

init(page: Page, children: [TreeNode] = [], parentNode: TreeNode? = nil) {
self.page = page
self.pageID = TaggedManagedObjectID(page)
self.pageData = PageData(postID: page.postID, parentID: page.parentID)
self.children = children
self.parentNode = parentNode
}

// The `PageTree` type is used to loaded
// Some page There are pages They are pages that doesn't belong to the root level, but their parent pages haven't been loaded yet.
var isOrphan: Bool {
(page.parentID?.int64Value ?? 0) > 0 && parentNode == nil
(pageData.parentID?.int64Value ?? 0) > 0 && parentNode == nil
}

func dfsList() -> [Page] {
func dfsList(in context: NSManagedObjectContext) throws -> [Page] {
var pages = [Page]()
_ = depthFirstSearch { level, node in
node.page.hierarchyIndex = level
node.page.hasVisibleParent = node.parentNode != nil
pages.append(node.page)
_ = try depthFirstSearch { level, node in
let page = try context.existingObject(with: node.pageID)
page.hierarchyIndex = level
page.hasVisibleParent = node.parentNode != nil
pages.append(page)
return false
}
return pages
Expand All @@ -35,18 +42,18 @@ final class PageTree {
/// a boolean value indicate whether the search should be stopped.
/// - Returns: `true` if search has been stopped by the closure.
@discardableResult
func depthFirstSearch(using closure: (Int, TreeNode) -> Bool) -> Bool {
depthFirstSearch(level: 0, using: closure)
func depthFirstSearch(using closure: (Int, TreeNode) throws -> Bool) rethrows -> Bool {
try depthFirstSearch(level: 0, using: closure)
}

private func depthFirstSearch(level: Int, using closure: (Int, TreeNode) -> Bool) -> Bool {
let shouldStop = closure(level, self)
private func depthFirstSearch(level: Int, using closure: (Int, TreeNode) throws -> Bool) rethrows -> Bool {
let shouldStop = try closure(level, self)
if shouldStop {
return true
}

for child in children {
let shouldStop = child.depthFirstSearch(level: level + 1, using: closure)
let shouldStop = try child.depthFirstSearch(level: level + 1, using: closure)
if shouldStop {
return true
}
Expand Down Expand Up @@ -77,7 +84,7 @@ final class PageTree {
assert(parentID != 0)

return depthFirstSearch { _, node in
if node.page.postID == parentID {
if node.pageData.postID == parentID {
node.children.append(contentsOf: newNodes)
newNodes.forEach { $0.parentNode = node }
return true
Expand Down Expand Up @@ -136,7 +143,7 @@ final class PageTree {
nodes.remove(atOffsets: relocated)
orphanNodes = nodes.enumerated().reduce(into: [:]) { indexes, node in
if node.element.isOrphan {
let parentID = node.element.page.parentID ?? 0
let parentID = node.element.pageData.parentID ?? 0
indexes[parentID, default: []].append(node.offset)
}
}
Expand All @@ -145,7 +152,7 @@ final class PageTree {

private func add(_ newNodes: [TreeNode]) {
newNodes.forEach { newNode in
let parentID = newNode.page.parentID ?? 0
let parentID = newNode.pageData.parentID ?? 0

// If the new node is at the root level, then simply add it as a child.
if parentID == 0 {
Expand All @@ -170,14 +177,14 @@ final class PageTree {

/// Move all the nodes in the given argument to the current page tree.
private func merge(subtree: PageTree) {
var parentIDs = subtree.nodes.reduce(into: Set()) { $0.insert($1.page.parentID ?? 0) }
var parentIDs = subtree.nodes.reduce(into: Set()) { $0.insert($1.pageData.parentID ?? 0) }
// No need to look for root level
parentIDs.remove(0)
// Look up parent nodes upfront, to avoid repeated iteration for each node in `subtree`.
let parentNodes = findNodes(postIDs: parentIDs)

subtree.nodes.forEach { newNode in
let parentID = newNode.page.parentID ?? 0
let parentID = newNode.pageData.parentID ?? 0

// If the new node is at the root level, then simply add it as a child
if parentID == 0 {
Expand Down Expand Up @@ -214,7 +221,7 @@ final class PageTree {

// Using BFS under the assumption that page tree in most sites is a shallow tree, where most pages are in top layers.
child.breadthFirstSearch { node in
let postID = node.page.postID ?? 0
let postID = node.pageData.postID ?? 0
let foundIndex = ids.firstIndex(of: postID)
if let foundIndex {
ids.remove(at: foundIndex)
Expand All @@ -227,15 +234,19 @@ final class PageTree {
return result
}

func hierarchyList() -> [Page] {
nodes.reduce(into: []) {
$0.append(contentsOf: $1.dfsList())
func hierarchyList(in context: NSManagedObjectContext) throws -> [Page] {
try nodes.reduce(into: []) {
try $0.append(contentsOf: $1.dfsList(in: context))
}
}

static func hierarchyList(of pages: [Page]) -> [Page] {
static func hierarchyList(of pages: [Page]) throws -> [Page] {
guard let context = pages.first?.managedObjectContext else {
return []
}

let tree = PageTree()
tree.add(pages)
return tree.hierarchyList()
return try tree.hierarchyList(in: context)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ extension PageListViewController: InteractivePostViewDelegate {

func setParent(for apost: AbstractPost) {
guard let page = apost as? Page else { return }
setParentPage(for: page)
Task {
await setParentPage(for: page)
}
}

func setHomepage(for apost: AbstractPost) {
Expand Down
81 changes: 70 additions & 11 deletions WordPress/Classes/ViewRelated/Pages/PageListViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ final class PageListViewController: AbstractPostListViewController, UIViewContro

private var pages: [Page] = []

private var fetchAllPagesTask: Task<[TaggedManagedObjectID<Page>], Error>?

// MARK: - Convenience constructors

@objc class func controllerWithBlog(_ blog: Blog) -> PageListViewController {
Expand Down Expand Up @@ -131,6 +133,11 @@ final class PageListViewController: AbstractPostListViewController, UIViewContro
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
QuickStartTourGuide.shared.endCurrentTour()

if self.isMovingFromParent {
fetchAllPagesTask?.cancel()
fetchAllPagesTask = nil
}
}

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
Expand Down Expand Up @@ -165,6 +172,22 @@ final class PageListViewController: AbstractPostListViewController, UIViewContro
return .page
}

@MainActor
override func syncPosts(isFirstPage: Bool) async throws -> ([AbstractPost], Bool) {
let coreDataStack = ContextManager.shared
let filter = filterSettings.currentPostListFilter()
let author = filterSettings.shouldShowOnlyMyPosts() ? blogUserID() : nil
let blogID = TaggedManagedObjectID(blog)

let repository = PostRepository(coreDataStack: coreDataStack)
let task = repository.fetchAllPages(statuses: filter.statuses, authorUserID: author, in: blogID)
self.fetchAllPagesTask = task

let posts = try await task.value.map { try coreDataStack.mainContext.existingObject(with: $0) }

return (posts, false)
}

override func syncHelper(_ syncHelper: WPContentSyncHelper, syncContentWithUserInteraction userInteraction: Bool, success: ((Bool) -> ())?, failure: ((NSError) -> ())?) {
// The success and failure blocks are called in the parent class `AbstractPostListViewController` by the `syncPosts` method. Since that class is
// used by both this one and the "Posts" screen, making changes to the sync helper is tough. To get around that, we make the fetch settings call
Expand Down Expand Up @@ -210,17 +233,55 @@ final class PageListViewController: AbstractPostListViewController, UIViewContro
override func updateAndPerformFetchRequest() {
super.updateAndPerformFetchRequest()

reloadPages()
Task {
await reloadPagesAndUI()
}
}

private func reloadPages() {
@MainActor
private func reloadPagesAndUI() async {
let status = filterSettings.currentPostListFilter().filterType
let pages = (fetchResultsController.fetchedObjects ?? []) as! [Page]

if status == .published {
self.pages = pages.setHomePageFirst().hierarchySort()
let coreDataStack = ContextManager.shared
let pageIDs = pages.map { TaggedManagedObjectID($0) }

do {
self.pages = try await buildPageTree(pageIDs: pageIDs)
.hierarchyList(in: coreDataStack.mainContext)
} catch {
DDLogError("Failed to reload published pages: \(error)")
}
} else {
self.pages = pages
}

tableView.reloadData()
refreshResults()
}

/// Build page hierachy in background, which should not take long (less than 2 seconds for 6000+ pages).
Copy link
Contributor

Choose a reason for hiding this comment

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

Show off 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

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

😅 Taking 2 seconds to sort 6000 items is not really fast though...It's much faster than what we have at the moment, but still not fast enough. I'm being lazy here by putting them into a background thread. The comment here is mainly pointing out, the sorting isn't fast enough to take place in the main thread, but is fast enough to put in the background thread without worrying it keep going forever.

@MainActor
func buildPageTree(pageIDs: [TaggedManagedObjectID<Page>]? = nil, request: NSFetchRequest<Page>? = nil) async throws -> PageTree {
assert(pageIDs != nil || request != nil, "`pageIDs` and `request` can not both be nil")

let coreDataStack = ContextManager.shared
return try await coreDataStack.performQuery { context in
var pages = [Page]()

if let pageIDs {
pages = try pageIDs.map(context.existingObject(with:))
} else if let request {
pages = try context.fetch(request)
}

pages = pages.setHomePageFirst()

let tree = PageTree()
tree.add(pages)
return tree
}
}

override func syncContentEnded(_ syncHelper: WPContentSyncHelper) {
Expand All @@ -241,9 +302,9 @@ final class PageListViewController: AbstractPostListViewController, UIViewContro
}

override func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
reloadPages()
tableView.reloadData()
refreshResults()
Task {
await reloadPagesAndUI()
}
}

// MARK: - Core Data
Expand Down Expand Up @@ -394,16 +455,14 @@ final class PageListViewController: AbstractPostListViewController, UIViewContro

// MARK: - Cell Action Handling

func setParentPage(for page: Page) {
@MainActor
func setParentPage(for page: Page) async {
let request = NSFetchRequest<Page>(entityName: Page.entityName())
let filter = PostListFilter.publishedFilter()
request.predicate = filter.predicate(for: blog, author: .everyone)
request.sortDescriptors = filter.sortDescriptors
do {
var pages = try managedObjectContext()
.fetch(request)
.setHomePageFirst()
.hierarchySort()
var pages = try await buildPageTree(request: request).hierarchyList(in: ContextManager.shared.mainContext)
if let index = pages.firstIndex(of: page) {
pages = pages.remove(from: index)
}
Expand Down
Loading