-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
Fix RootViewModel memory leaks #623
Changes from 12 commits
cce5422
a8f6952
2bceffd
6ac2fc8
0129450
8768479
57d44ac
549c67b
9af9a53
c585002
a1e35da
2079f91
040fd22
695e5bb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,29 +5,97 @@ import ReactiveSwift | |
import Result | ||
import UIKit | ||
|
||
typealias RootViewControllerIndex = Int | ||
|
||
internal enum RootViewControllerData: Equatable { | ||
case discovery | ||
case activities | ||
case search | ||
case dashboard(isMember: Bool) | ||
case profile(isLoggedIn: Bool) | ||
|
||
var viewController: UIViewController? { | ||
switch self { | ||
case .discovery: | ||
return DiscoveryViewController.instantiate() | ||
case .activities: | ||
return ActivitiesViewController.instantiate() | ||
case.search: | ||
return SearchViewController.instantiate() | ||
case .dashboard(let isMember): | ||
return isMember ? DashboardViewController.instantiate() : nil | ||
case .profile(let isLoggedIn): | ||
return isLoggedIn | ||
? BackerDashboardViewController.instantiate() | ||
: LoginToutViewController.configuredWith(loginIntent: .generic) | ||
} | ||
} | ||
|
||
static func == (lhs: RootViewControllerData, rhs: RootViewControllerData) -> Bool { | ||
switch (lhs, rhs) { | ||
case (.discovery, .discovery): return true | ||
case (.activities, .activities): return true | ||
case (.search, .search): return true | ||
case (.dashboard(let lhsIsMember), .dashboard(let rhsIsMember)): | ||
return lhsIsMember == rhsIsMember | ||
case (.profile(let lhsIsLoggedIn), .profile(let rhsIsLoggedIn)): | ||
return lhsIsLoggedIn == rhsIsLoggedIn | ||
default: | ||
return false | ||
} | ||
} | ||
|
||
var isNil: Bool { | ||
switch self { | ||
case .dashboard(let isMember): | ||
return !isMember | ||
default: | ||
return false | ||
} | ||
} | ||
|
||
var isDashBoard: Bool { | ||
switch self { | ||
case .dashboard: | ||
return true | ||
default: | ||
return false | ||
} | ||
} | ||
|
||
var isProfile: Bool { | ||
switch self { | ||
case .profile: | ||
return true | ||
default: | ||
return false | ||
} | ||
} | ||
} | ||
|
||
internal struct TabBarItemsData { | ||
internal let items: [TabBarItem] | ||
internal let isLoggedIn: Bool | ||
internal let isMember: Bool | ||
} | ||
|
||
internal enum TabBarItem { | ||
case activity(index: Int) | ||
case dashboard(index: Int) | ||
case home(index: Int) | ||
case profile(avatarUrl: URL?, index: Int) | ||
case search(index: Int) | ||
case activity(index: RootViewControllerIndex) | ||
case dashboard(index: RootViewControllerIndex) | ||
case home(index: RootViewControllerIndex) | ||
case profile(avatarUrl: URL?, index: RootViewControllerIndex) | ||
case search(index: RootViewControllerIndex) | ||
} | ||
|
||
internal protocol RootViewModelInputs { | ||
/// Call when the controller has received a user updated notification. | ||
func currentUserUpdated() | ||
|
||
/// Call before selected tab bar index changes. | ||
func shouldSelect(index: Int?) | ||
func shouldSelect(index: RootViewControllerIndex?) | ||
|
||
/// Call when selected tab bar index changes. | ||
func didSelect(index: Int) | ||
func didSelect(index: RootViewControllerIndex) | ||
|
||
/// Call when we should switch to the activities tab. | ||
func switchToActivities() | ||
|
@@ -62,20 +130,20 @@ internal protocol RootViewModelInputs { | |
|
||
internal protocol RootViewModelOutputs { | ||
/// Emits when the discovery VC should filter with specific params. | ||
var filterDiscovery: Signal<(DiscoveryViewController, DiscoveryParams), NoError> { get } | ||
var filterDiscovery: Signal<(RootViewControllerIndex, DiscoveryParams), NoError> { get } | ||
|
||
/// Emits a controller that should be scrolled to the top. This requires figuring out what kind of | ||
/// Emits a controller index that should be scrolled to the top. This requires figuring out what kind of | ||
/// controller it is, and setting its `contentOffset`. | ||
var scrollToTop: Signal<UIViewController, NoError> { get } | ||
var scrollToTop: Signal<RootViewControllerIndex, NoError> { get } | ||
|
||
/// Emits an index that the tab bar should be switched to. | ||
var selectedIndex: Signal<Int, NoError> { get } | ||
var selectedIndex: Signal<RootViewControllerIndex, NoError> { get } | ||
|
||
/// Emits the array of view controllers that should be set on the tab bar. | ||
var setViewControllers: Signal<[UIViewController], NoError> { get } | ||
var setViewControllers: Signal<[RootViewControllerData], NoError> { get } | ||
|
||
/// Emits when the dashboard should switch projects. | ||
var switchDashboardProject: Signal<(DashboardViewController, Param), NoError> { get } | ||
var switchDashboardProject: Signal<(RootViewControllerIndex, Param), NoError> { get } | ||
|
||
/// Emits data for setting tab bar item styles. | ||
var tabBarItemsData: Signal<TabBarItemsData, NoError> { get } | ||
|
@@ -103,25 +171,22 @@ internal final class RootViewModel: RootViewModelType, RootViewModelInputs, Root | |
|
||
let standardViewControllers = self.viewDidLoadProperty.signal.map { generateStandardViewControllers() } | ||
let personalizedViewControllers = userState.map { generatePersonalizedViewControllers(userState: $0) } | ||
.map { $0.compact() } | ||
|
||
let viewControllers = Signal.combineLatest(standardViewControllers, personalizedViewControllers).map(+) | ||
|
||
let refreshedViewControllers = userState.takeWhen(self.userLocalePreferencesChangedProperty.signal) | ||
.map { userState -> [UIViewController?] in | ||
.map { userState -> [RootViewControllerData] in | ||
let standard = generateStandardViewControllers() | ||
let personalized = generatePersonalizedViewControllers(userState: userState) | ||
|
||
return [standard, personalized].flatMap { $0 } | ||
return standard + personalized | ||
} | ||
.map { $0.compact() } | ||
|
||
self.setViewControllers = Signal.merge( | ||
viewControllers, | ||
refreshedViewControllers | ||
).map { | ||
$0.map(UINavigationController.init(rootViewController:)) | ||
} | ||
) | ||
.map { $0.filter { !$0.isNil } } | ||
|
||
let loginState = userState.map { $0.isLoggedIn } | ||
let vcCount = self.setViewControllers.map { $0.count } | ||
|
@@ -136,60 +201,50 @@ internal final class RootViewModel: RootViewModelType, RootViewModelInputs, Root | |
.filter { isTrue($1) } | ||
.map(first) | ||
|
||
let discovery = viewControllers | ||
.map(first(DiscoveryViewController.self)) | ||
let discoveryControllerIndex = self.setViewControllers | ||
.map { $0.index(of: .discovery) } | ||
.skipNil() | ||
|
||
self.filterDiscovery = discovery | ||
self.filterDiscovery = discoveryControllerIndex | ||
.takePairWhen(self.switchToDiscoveryProperty.signal.skipNil()) | ||
|
||
let dashboard = viewControllers | ||
.map(first(DashboardViewController.self)) | ||
let dashboardControllerIndex = self.setViewControllers | ||
.map { $0.index(where: { $0.isDashBoard }) } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there actually a need for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
.skipNil() | ||
|
||
self.switchDashboardProject = | ||
Signal.combineLatest(dashboard, self.switchToDashboardProperty.signal.skipNil(), | ||
loginState) | ||
self.switchDashboardProject = Signal | ||
.combineLatest(dashboardControllerIndex, self.switchToDashboardProperty.signal.skipNil(), loginState) | ||
.filter { _, _, loginState in | ||
isTrue(loginState) | ||
} | ||
.map { dashboard, param, _ in | ||
(dashboard, param) | ||
} | ||
|
||
self.selectedIndex = | ||
Signal.combineLatest( | ||
.merge( | ||
self.viewDidLoadProperty.signal.mapConst(0), | ||
self.didSelectIndexProperty.signal, | ||
self.switchToActivitiesProperty.signal.mapConst(1), | ||
self.switchToDiscoveryProperty.signal.mapConst(0), | ||
self.switchToSearchProperty.signal.mapConst(2), | ||
switchToLogin, | ||
switchToProfile, | ||
self.switchToDashboardProperty.signal.mapConst(3) | ||
), | ||
self.setViewControllers, | ||
self.viewDidLoadProperty.signal) | ||
.map { idx, vcs, _ in clamp(0, vcs.count - 1)(idx) } | ||
self.selectedIndex = Signal.combineLatest( | ||
.merge( | ||
self.viewDidLoadProperty.signal.mapConst(0), | ||
self.didSelectIndexProperty.signal, | ||
self.switchToActivitiesProperty.signal.mapConst(1), | ||
self.switchToDiscoveryProperty.signal.mapConst(0), | ||
self.switchToSearchProperty.signal.mapConst(2), | ||
switchToLogin, | ||
switchToProfile, | ||
self.switchToDashboardProperty.signal.mapConst(3) | ||
), | ||
self.setViewControllers, | ||
self.viewDidLoadProperty.signal | ||
) | ||
.map { idx, vcs, _ in clamp(0, vcs.count - 1)(idx) } | ||
|
||
let shouldSelectIndex = self.shouldSelectIndexProperty.signal | ||
.skipNil() | ||
|
||
let selectedTabAgain = self.selectedIndex | ||
self.scrollToTop = self.selectedIndex | ||
.takePairWhen(shouldSelectIndex) | ||
.filter { prev, next in prev == next } | ||
.map { $1 } | ||
|
||
self.scrollToTop = Signal.combineLatest( | ||
self.setViewControllers, | ||
selectedTabAgain | ||
) | ||
.filter { vcs, idx in idx < vcs.count } | ||
.map { vcs, idx in vcs[idx] } | ||
.map(extractViewController) | ||
.skipNil() | ||
|
||
self.tabBarItemsData = Signal.combineLatest(currentUser, .merge( | ||
self.viewDidLoadProperty.signal, | ||
self.userLocalePreferencesChangedProperty.signal.ignoreValues()) | ||
|
@@ -212,26 +267,32 @@ internal final class RootViewModel: RootViewModelType, RootViewModelInputs, Root | |
internal func didSelect(index: Int) { | ||
self.didSelectIndexProperty.value = index | ||
} | ||
|
||
fileprivate let switchToActivitiesProperty = MutableProperty(()) | ||
internal func switchToActivities() { | ||
self.switchToActivitiesProperty.value = () | ||
} | ||
|
||
fileprivate let switchToDashboardProperty = MutableProperty<Param?>(nil) | ||
internal func switchToDashboard(project param: Param?) { | ||
self.switchToDashboardProperty.value = param | ||
} | ||
|
||
fileprivate let switchToDiscoveryProperty = MutableProperty<DiscoveryParams?>(nil) | ||
internal func switchToDiscovery(params: DiscoveryParams?) { | ||
self.switchToDiscoveryProperty.value = params | ||
} | ||
|
||
fileprivate let switchToLoginProperty = MutableProperty(()) | ||
internal func switchToLogin() { | ||
self.switchToLoginProperty.value = () | ||
} | ||
|
||
fileprivate let switchToProfileProperty = MutableProperty(()) | ||
internal func switchToProfile() { | ||
self.switchToProfileProperty.value = () | ||
} | ||
|
||
fileprivate let switchToSearchProperty = MutableProperty(()) | ||
internal func switchToSearch() { | ||
self.switchToSearchProperty.value = () | ||
|
@@ -256,33 +317,24 @@ internal final class RootViewModel: RootViewModelType, RootViewModelInputs, Root | |
self.viewDidLoadProperty.value = () | ||
} | ||
|
||
internal let filterDiscovery: Signal<(DiscoveryViewController, DiscoveryParams), NoError> | ||
internal let scrollToTop: Signal<UIViewController, NoError> | ||
internal let selectedIndex: Signal<Int, NoError> | ||
internal let setViewControllers: Signal<[UIViewController], NoError> | ||
internal let switchDashboardProject: Signal<(DashboardViewController, Param), NoError> | ||
internal let filterDiscovery: Signal<(RootViewControllerIndex, DiscoveryParams), NoError> | ||
internal let scrollToTop: Signal<RootViewControllerIndex, NoError> | ||
internal let selectedIndex: Signal<RootViewControllerIndex, NoError> | ||
internal let setViewControllers: Signal<[RootViewControllerData], NoError> | ||
internal let switchDashboardProject: Signal<(Int, Param), NoError> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this |
||
internal let tabBarItemsData: Signal<TabBarItemsData, NoError> | ||
|
||
internal var inputs: RootViewModelInputs { return self } | ||
internal var outputs: RootViewModelOutputs { return self } | ||
} | ||
|
||
private func generateStandardViewControllers() -> [UIViewController] { | ||
return [ | ||
DiscoveryViewController.instantiate(), | ||
ActivitiesViewController.instantiate(), | ||
SearchViewController.instantiate() | ||
] | ||
private func generateStandardViewControllers() -> [RootViewControllerData] { | ||
return [.discovery, .activities, .search] | ||
} | ||
|
||
private func generatePersonalizedViewControllers(userState: (isMember: Bool, isLoggedIn: Bool)) | ||
-> [UIViewController?] { | ||
let dashboardViewController: UIViewController? = userState.isMember | ||
? DashboardViewController.instantiate() : nil | ||
let loginProfileViewController: UIViewController = userState.isLoggedIn | ||
? profileController() : LoginToutViewController.configuredWith(loginIntent: .generic) | ||
|
||
return [dashboardViewController, loginProfileViewController] | ||
-> [RootViewControllerData] { | ||
return [.dashboard(isMember: userState.isMember), .profile(isLoggedIn: userState.isLoggedIn)] | ||
} | ||
|
||
private func tabData(forUser user: User?) -> TabBarItemsData { | ||
|
@@ -324,25 +376,3 @@ extension TabBarItem: Equatable { | |
} | ||
} | ||
} | ||
|
||
private func first<VC: UIViewController>(_ viewController: VC.Type) -> ([UIViewController]) -> VC? { | ||
|
||
return { viewControllers in | ||
viewControllers | ||
.index { $0 is VC } | ||
.flatMap { viewControllers[$0] as? VC } | ||
} | ||
} | ||
|
||
private func profileController() -> UIViewController { | ||
|
||
return BackerDashboardViewController.instantiate() | ||
} | ||
|
||
private func extractViewController(_ viewController: UIViewController) -> UIViewController? { | ||
if let navigationController = viewController as? UINavigationController { | ||
return navigationController.viewControllers.count == 1 ? navigationController.viewControllers.first : nil | ||
} else { | ||
return viewController | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,7 +13,7 @@ final class RootViewModelTests: TestCase { | |
let vm: RootViewModelType = RootViewModel() | ||
let viewControllerNames = TestObserver<[String], NoError>() | ||
let filterDiscovery = TestObserver<DiscoveryParams, NoError>() | ||
let selectedIndex = TestObserver<Int, NoError>() | ||
let selectedIndex = TestObserver<RootViewControllerIndex, NoError>() | ||
let scrollToTopControllerName = TestObserver<String, NoError>() | ||
let switchDashboardProject = TestObserver<Param, NoError>() | ||
let tabBarItemsData = TestObserver<TabBarItemsData, NoError>() | ||
|
@@ -29,7 +29,11 @@ final class RootViewModelTests: TestCase { | |
self.vm.outputs.selectedIndex.observe(self.selectedIndex.observer) | ||
self.vm.outputs.switchDashboardProject.map(second).observe(self.switchDashboardProject.observer) | ||
|
||
self.vm.outputs.scrollToTop | ||
let viewControllers = self.vm.outputs.setViewControllers | ||
.map { $0.map { $0.viewController }.compact() } | ||
|
||
Signal.combineLatest(viewControllers, self.vm.outputs.scrollToTop) | ||
.map { (vcs, idx) in vcs[idx] } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do you think about also checkin whether |
||
.map(extractName) | ||
.observe(self.scrollToTopControllerName.observer) | ||
|
||
|
@@ -165,7 +169,7 @@ final class RootViewModelTests: TestCase { | |
|
||
self.vm.inputs.didSelect(index: 10) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we still keep this test? I'm thinking about the scenario where a creator is logged in (5 tabs) and logs out (4 tabs). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure, can't remember why I removed it. |
||
|
||
self.selectedIndex.assertValues([0, 1, 0, 3], "Selecting index out of range safely clamps to bounds.") | ||
self.selectedIndex.assertValues([0, 1, 0, 3], "Selects index immediately.") | ||
} | ||
|
||
func testScrollToTop() { | ||
|
@@ -328,8 +332,12 @@ final class RootViewModelTests: TestCase { | |
} | ||
} | ||
|
||
private func extractRootNames(_ vcs: [UIViewController]) -> [String] { | ||
return vcs.compactMap(extractRootName) | ||
private func extractRootNames(_ vcs: [RootViewControllerData]) -> [String] { | ||
return vcs | ||
.map { $0.viewController } | ||
.compact() | ||
.map(UINavigationController.init(rootViewController:)) | ||
.compactMap(extractRootName) | ||
} | ||
|
||
private func extractRootName(_ vc: UIViewController) -> String? { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it make sense to rename this to
viewControllersData
or similar?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that this type is specific to the
RootViewModel
andRootTabBarViewController
so it shouldn't really be used anywhere else.