Memory management (#503)

* Correctly tear down the user session on signing out

* Fix session verification <-> user session retain cycle, visible range debouncer leak

* Manually clean up coordinators retained within SwiftUI's NavigationStacks

* Slightly refactor the timeline content menu builder and prevent it from retaining the view model. Cleanup now unnecessarily optional RoomScreenCoordinator instance vars

* Move coordinator dismissal logic to the navigation modules
This commit is contained in:
Stefan Ceriu
2023-01-31 11:51:56 +02:00
committed by GitHub
parent 70cff446cf
commit d51a9b3a2b
13 changed files with 152 additions and 147 deletions
@@ -25,15 +25,11 @@ struct RoomScreenCoordinatorParameters {
}
final class RoomScreenCoordinator: CoordinatorProtocol {
private var parameters: RoomScreenCoordinatorParameters?
private var parameters: RoomScreenCoordinatorParameters
private var viewModel: RoomScreenViewModelProtocol?
private var viewModel: RoomScreenViewModelProtocol
private var navigationStackCoordinator: NavigationStackCoordinator {
guard let parameters else {
fatalError()
}
return parameters.navigationStackCoordinator
parameters.navigationStackCoordinator
}
init(parameters: RoomScreenCoordinatorParameters) {
@@ -49,7 +45,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
// MARK: - Public
func start() {
viewModel?.callback = { [weak self] action in
viewModel.callback = { [weak self] action in
guard let self else { return }
switch action {
@@ -64,18 +60,11 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
}
func stop() {
viewModel?.context.send(viewAction: .markRoomAsRead)
viewModel?.stop()
viewModel = nil
parameters = nil
viewModel.context.send(viewAction: .markRoomAsRead)
}
func toPresentable() -> AnyView {
guard let context = viewModel?.context else {
fatalError()
}
return AnyView(RoomScreen(context: context))
AnyView(RoomScreen(context: viewModel.context))
}
// MARK: - Private
@@ -91,14 +80,9 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
}
private func displayEmojiPickerScreen(for itemId: String) {
guard let emojiProvider = parameters?.emojiProvider,
let timelineController = parameters?.timelineController else {
fatalError()
}
let emojiPickerNavigationStackCoordinator = NavigationStackCoordinator()
let params = EmojiPickerScreenCoordinatorParameters(emojiProvider: emojiProvider,
let params = EmojiPickerScreenCoordinatorParameters(emojiProvider: parameters.emojiProvider,
itemId: itemId)
let coordinator = EmojiPickerScreenCoordinator(parameters: params)
coordinator.callback = { [weak self] action in
@@ -107,7 +91,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
MXLog.debug("Selected \(emoji) for \(itemId)")
self?.navigationStackCoordinator.setSheetCoordinator(nil)
Task {
await timelineController.sendReaction(emoji, to: itemId)
await self?.parameters.timelineController.sendReaction(emoji, to: itemId)
}
case .dismiss:
self?.navigationStackCoordinator.setSheetCoordinator(nil)
@@ -121,14 +105,9 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
}
private func displayRoomDetails() {
guard let roomProxy = parameters?.roomProxy,
let mediaProvider = parameters?.mediaProvider else {
return
}
let params = RoomDetailsCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: roomProxy,
mediaProvider: mediaProvider)
roomProxy: parameters.roomProxy,
mediaProvider: parameters.mediaProvider)
let coordinator = RoomDetailsCoordinator(parameters: params)
coordinator.callback = { [weak self] _ in
self?.navigationStackCoordinator.pop()
@@ -44,6 +44,7 @@ enum RoomScreenViewAction {
case cancelEdit
/// Mark the entire room as read - this is heavy handed as a starting point for now.
case markRoomAsRead
case contextMenuAction(itemID: String, action: TimelineItemContextMenuAction)
}
struct RoomScreenViewState: BindableState {
@@ -56,7 +57,7 @@ struct RoomScreenViewState: BindableState {
var showLoading = false
var bindings: RoomScreenViewStateBindings
var contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)?
var contextMenuActionProvider: (@MainActor (_ itemId: String) -> TimelineItemContextMenuActions?)?
var composerMode: RoomScreenComposerMode = .default
@@ -28,9 +28,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
private let timelineController: RoomTimelineControllerProtocol
private let timelineViewFactory: RoomTimelineViewFactoryProtocol
// MARK: - Setup
init(timelineController: RoomTimelineControllerProtocol,
timelineViewFactory: RoomTimelineViewFactoryProtocol,
mediaProvider: MediaProviderProtocol,
@@ -72,7 +70,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
.store(in: &cancellables)
state.contextMenuBuilder = buildContextMenuForItemId(_:)
state.contextMenuActionProvider = { [weak self] itemId -> TimelineItemContextMenuActions? in
guard let self else {
return nil
}
return self.contextMenuActionsForItemId(itemId)
}
buildTimelineViews()
}
@@ -109,13 +113,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.bindings.composerText = ""
case .markRoomAsRead:
await markRoomAsRead()
case .contextMenuAction(let itemID, let action):
processContentMenuAction(action, itemID: itemID)
}
}
func stop() {
cancellables.removeAll()
state.contextMenuBuilder = nil
}
// MARK: - Private
@@ -197,22 +198,16 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
// MARK: ContextMenus
private func buildContextMenuForItemId(_ itemId: String) -> TimelineItemContextMenu {
TimelineItemContextMenu(contextMenuActions: contextMenuActionsForItemId(itemId)) { [weak self] action in
self?.processContentMenuAction(action, itemId: itemId)
}
}
private func contextMenuActionsForItemId(_ itemId: String) -> TimelineItemContextMenuActions {
private func contextMenuActionsForItemId(_ itemId: String) -> TimelineItemContextMenuActions? {
guard let timelineItem = timelineController.timelineItems.first(where: { $0.id == itemId }),
let item = timelineItem as? EventBasedTimelineItemProtocol else {
// Don't show a context menu for non-event based items.
return .empty
return nil
}
if timelineItem is StateRoomTimelineItem {
// Don't show a context menu for state events.
return .empty
return nil
}
var actions: [TimelineItemContextMenuAction] = [
@@ -238,8 +233,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
// swiftlint:disable:next cyclomatic_complexity
private func processContentMenuAction(_ action: TimelineItemContextMenuAction, itemId: String) {
guard let timelineItem = timelineController.timelineItems.first(where: { $0.id == itemId }),
private func processContentMenuAction(_ action: TimelineItemContextMenuAction, itemID: String) {
guard let timelineItem = timelineController.timelineItems.first(where: { $0.id == itemID }),
let item = timelineItem as? EventBasedTimelineItemProtocol else {
return
}
@@ -265,7 +260,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
case .redact:
Task {
await timelineController.redact(itemId)
await timelineController.redact(itemID)
}
case .reply:
state.bindings.composerFocused = true
@@ -20,6 +20,4 @@ import Foundation
protocol RoomScreenViewModelProtocol {
var callback: ((RoomScreenViewModelAction) -> Void)? { get set }
var context: RoomScreenViewModelType.Context { get }
func stop()
}
@@ -17,11 +17,17 @@
import SwiftUI
struct TimelineItemContextMenuActions {
static var empty: TimelineItemContextMenuActions { .init(actions: [], debugActions: []) }
let actions: [TimelineItemContextMenuAction]
let debugActions: [TimelineItemContextMenuAction]
var isEmpty: Bool { actions.isEmpty && debugActions.isEmpty }
init?(actions: [TimelineItemContextMenuAction], debugActions: [TimelineItemContextMenuAction]) {
if actions.isEmpty, debugActions.isEmpty {
return nil
}
self.actions = actions
self.debugActions = debugActions
}
}
enum TimelineItemContextMenuAction: Identifiable, Hashable {
@@ -52,16 +58,11 @@ public struct TimelineItemContextMenu: View {
let callback: (TimelineItemContextMenuAction) -> Void
public var body: some View {
if contextMenuActions.isEmpty {
// When there are no actions make sure then menu isn't shown.
EmptyView()
} else {
viewsForActions(contextMenuActions.actions)
Menu {
viewsForActions(contextMenuActions.debugActions)
} label: {
Label("Developer", systemImage: "hammer")
}
viewsForActions(contextMenuActions.actions)
Menu {
viewsForActions(contextMenuActions.debugActions)
} label: {
Label("Developer", systemImage: "hammer")
}
}
@@ -72,7 +72,7 @@ class TimelineTableViewController: UIViewController {
}
}
var contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)?
var contextMenuActionProvider: (@MainActor (_ itemId: String) -> TimelineItemContextMenuActions?)?
@Binding private var scrollToBottomButtonVisible: Bool
@@ -189,7 +189,7 @@ class TimelineTableViewController: UIViewController {
// A local reference to avoid capturing self in the cell configuration.
let coordinator = self.coordinator
let opacity = self.opacity(for: timelineItem)
let contextMenuBuilder = self.contextMenuBuilder
let contextMenuActionProvider = self.contextMenuActionProvider
cell.item = timelineItem
cell.contentConfiguration = UIHostingConfiguration {
@@ -198,7 +198,11 @@ class TimelineTableViewController: UIViewController {
.frame(maxWidth: .infinity, alignment: .leading)
.opacity(opacity)
.contextMenu {
contextMenuBuilder?(timelineItem.id)
contextMenuActionProvider?(timelineItem.id).map { actions in
TimelineItemContextMenu(contextMenuActions: actions) { action in
coordinator.send(viewAction: .contextMenuAction(itemID: timelineItem.id, action: action))
}
}
}
.onAppear {
coordinator.send(viewAction: .itemAppeared(id: timelineItem.id))
@@ -70,7 +70,7 @@ struct TimelineView: UIViewControllerRepresentable {
}
// Doesn't have an equatable conformance :(
tableViewController.contextMenuBuilder = context.viewState.contextMenuBuilder
tableViewController.contextMenuActionProvider = context.viewState.contextMenuActionProvider
}
func send(viewAction: RoomScreenViewAction) {