appsquickly / typhoon

Powerful dependency injection for Objective-C ✨✨ (https://PILGRIM.PH is the pure Swift successor to Typhoon!!)✨✨
https://pilgrim.ph
Apache License 2.0
2.7k stars 269 forks source link

UI State Preservation/Restoration support on TyphoonStoryboard #229

Closed ghost closed 10 years ago

ghost commented 10 years ago

I saw this page ( http://stackoverflow.com/questions/18998759/typhoon-assembly-and-storyboard-created-viewcontrollers ) and introduced TyphoonStoryboard to my project to inject to View Controllers instantiated from Storyboard, and it works well, but UI State Preservation/Restoration compatibility was lost in exchange for it.

To make UI State Preservation/Restoration be compatible with TyphoonStoryboard, I tried following solutions:

1. Exchanging constructors between UIStoryboard and TyphoonStoryboard

According to this page ( http://qiita.com/tomohisaota/items/eb7fca833f808d52b4c2 ), using run-time-method-swizzling provided by objc runtime, we can use TyphoonStoryboard as UIStoryboard. In this way, we can use "Main" storyboard on Info-Plist, and don't have to construct UIWindow, TyphoonStoryboard, and Root View Controller on application:didFinishLaunchingWithOptions:. Method exchanging seems to have to be performed before beginning of UI State Restoration process, so I added exchange codes to main() function:

main.m

@interface UIStoryboard (TyphoonConstructorExchanging)

+ (UIStoryboard *)typhoonStoryboardWithName:(NSString *)name bundle:(NSBundle *)storyboardBundleOrNil;
+ (void)exchangeConstructor;

@end

@interface TyphoonComponentFactory (TyphoonDefaultFactoryPreparation)

+ (void)prepareDefaultFactory;

@end

@implementation UIStoryboard (TyphoonConstructorExchanging)

+ (UIStoryboard *)typhoonStoryboardWithName:(NSString *)name bundle:(NSBundle *)storyboardBundleOrNil
{
    if(self != [UIStoryboard class]){
        return [self typhoonStoryboardWithName:name bundle:storyboardBundleOrNil];
    }
    return [TyphoonStoryboard storyboardWithName:name
                                         factory:[TyphoonComponentFactory defaultFactory]
                                          bundle:storyboardBundleOrNil];
}

+ (void)exchangeConstructor {
    Method original,exchanged;
    original = class_getClassMethod([UIStoryboard class], @selector(storyboardWithName:bundle:));
    exchanged = class_getClassMethod([UIStoryboard class], @selector(typhoonStoryboardWithName:bundle:));
    method_exchangeImplementations(original, exchanged);
}

@end

@implementation TyphoonComponentFactory (TyphoonDefaultFactoryPreparation)

+ (void)prepareDefaultFactory {
    TyphoonComponentFactory *factory = [TyphoonBlockComponentFactory factoryWithAssembly:[MyAssembly assembly]];
    [factory makeDefault];
}

@end

int main(int argc, char * argv[])
{
    @autoreleasepool {
        [TyphoonComponentFactory prepareDefaultFactory];
        [UIStoryboard exchangeConstructor];
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([MyAppDelegate class]));
    }
}

I think this way is smart but method-exchanging seems to be tricky and risky. So I tried following way:

2. Using Restoration Class and manual preservation/restoration

I added Restoration Class instantiating view controllers from TyphoonStoryboard on restoration process, and injected restoration class on TyphoonAssembly subclass, and added codes to encode/decode root view controller manually on App Delegate.

MyAppDelegate.m

- (BOOL)application:(UIApplication *)application shouldRestoreApplicationState:(NSCoder *)coder {
    return YES;
}

- (BOOL)application:(UIApplication *)application shouldSaveApplicationState:(NSCoder *)coder {
    return YES;
}

static NSString *const RootViewControllerKey = @"RootViewController";

- (void)application:(UIApplication *)application willEncodeRestorableStateWithCoder:(NSCoder *)coder {
    [coder encodeObject:self.window.rootViewController forKey:RootViewControllerKey];
}

- (void)application:(UIApplication *)application didDecodeRestorableStateWithCoder:(NSCoder *)coder {
    UIViewController *rootViewController = [coder decodeObjectForKey:RootViewControllerKey];
    if (rootViewController) {
        self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
        self.window.rootViewController = rootViewController;
    }
}

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    if (!self.window) {
        TyphoonComponentFactory *factory = [TyphoonBlockComponentFactory factoryWithAssembly:[MyAssembly assembly]];
        self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
        TyphoonStoryboard *storyboard = [TyphoonStoryboard storyboardWithName:@"Main" factory:factory bundle:nil];
        self.window.rootViewController = [storyboard instantiateInitialViewController];
    }
    [self.window makeKeyAndVisible];
    return YES;
}

MyAssembly.m

- (id)masterViewController {
    return [TyphoonDefinition withClass:[MyMasterViewController class] configuration:^(TyphoonDefinition *definition) {
        [definition injectProperty:@selector(restorationClass) with:[TyphoonRestoration class]];
    }];
}

- (id)detailViewController {
    return [TyphoonDefinition withClass:[MyDetailViewController class] configuration:^(TyphoonDefinition *definition) {
        [definition injectProperty:@selector(restorationClass) with:[TyphoonRestoration class]];
    }];
}

- (id)navigationController {
    return [TyphoonDefinition withClass:[UINavigationController class] configuration:^(TyphoonDefinition *definition) {
        [definition injectProperty:@selector(restorationClass) with:[TyphoonRestoration class]];
    }];
}

TyphoonRestoration.m

+ (TyphoonStoryboard *)typhoonStoryboard {
    TyphoonComponentFactory *factory = [TyphoonBlockComponentFactory factoryWithAssembly:[MyAssembly assembly]];
    TyphoonStoryboard *storyboard = [TyphoonStoryboard storyboardWithName:@"Main" factory:factory bundle:nil];
    return storyboard;
}

+ (UIViewController *)viewControllerWithRestorationIdentifierPath:(NSArray *)identifierComponents coder:(NSCoder *)coder {
    TyphoonStoryboard *storyboard = [self typhoonStoryboard];
    NSString *identifier = [identifierComponents lastObject];
    id viewController = [storyboard instantiateViewControllerWithIdentifier:identifier];
    return viewController;
}

This way doesn't need tricky technique such as method-exchanging but I think it's not smart.

Is there more suitable way to make UI State Preservation/Restoration be compatible with TyphoonStoryboard?

alexgarbarev commented 10 years ago

We are not using method swizzling because is uses defaultFactory, I don't like singleton for factory. Second solution, with TyphoonRestoration seems that needed singleton too (in typhoonStoryboard method).

I think solution should use method swizzling and not use defaultFactory or other singletons

alexgarbarev commented 10 years ago

Thanks for great research! I never used UI State Restoration before - it is reason why TyphoonStoryboard didn't tested with this case.

ghost commented 10 years ago

Oh, It was indiscreet of me to use defaultFactory. I agree that singleton is not good, too, because it causes easily unwanted dependency.

But anyway, I think we must use something tricky such as above solutions whether we use singleton or not, is that light?

UI State Restoration is useful especially for large-scale applications, so I think it is good that Typhoon provides some feature to support UI State Restoration or documentation to make implementation easy.

I wish my research to be helpful for Typhoon project.

Thank you.

jasperblues commented 10 years ago

Yes @Masatz , great work! Thanks for your contribution.

We now have two advantages of doing "transparent" Storyboard integration using mix-ins:

Perhaps @alexgarbarev has another Idea on how to make the 2nd case work. But here's a suggestion on doing it using swizzling and but still not requiring singletons or modifying main.m (until now, we've done all Typhoon set-up in Assembly/app-delegate)

ghost commented 10 years ago

Thank you for your advices.

I removed swizzling and making default factory codes and I tried to implement TyphoonIntegratedAppDelegate, as you described above.

As far as I know, app delegate's method which is called the first on launching app is application:willFinishLaunchingWithOption: or application:didFinishLaunchingWithOption:. So I moved codes to prepare factory and swizzle storyboard constructors into these methods with dispatch_once, but it didn't work well. I set symbolic breakpoint to determine when storyboard was constructed, and it was turned out that storyboard was constructed before application:willFinishLaunchingWithOption: or application:didFinishLaunchingWithOption: was called. So, I moved these codes to initialize of TyphoonIntegratedAppDelegate. Then, it worked well... but I'm not sure if this way is right or not.

Do you know good place to put these codes on except main.m?

alexgarbarev commented 10 years ago

I had idea and today I have a little time to implement this feature. Let me try..

alexgarbarev commented 10 years ago

This feature done! Typhoon will automatically swizzle initial UIStoryboard if needed at startup.

All you have to do is specifing initial factory. To specify initial factory two options available:

Option 1 implement in your AppDelegate -initialFactory method like tihs:

@implementation AppDelegate
...
- (TyphoonComponentFactory *)initialFactory
{
    return [TyphoonBlockComponentFactory factoryWithAssembly:[MainAssembly assembly]];
}
...
@end

Option 2 Edit Info.plist, add key TyphoonInitialAssemblies as Array where values are assembly class names.

Like this: infoplist

Initial factory usage If you specify initial storyboard in Info.plist (UIMainStoryboardFile key), Typhoon will swizzle UIStoryboard to replace initial UIStoryboard with TyphoonStoryboard initialized with initial factory.

Typhoon tries to inject dependencies into your 'AppDelegate' class at startup. If initial factory has definition for your AppDelegate class, this definition will be used to inject dependencies. In any case, you can implement next method in AppDelegate to get initial factory:

- (void)typhoonSetFactory:(id)factory

(typhoonDidInject hook also available) Note: injection to AppDelegate performs before applicationDidFinishLaunching called

jasperblues commented 10 years ago

Great work Aleksey! This is looking really polished now.

Noted also that we can use this to get AppDelegate injection even if not using Storyboards. (As an alternative to pulling dependencies from Typhoon from within the AppDelegate).

jasperblues commented 10 years ago

@Masatz would you like to verify this one again? To test:

pod 'Typhoon', :head

. . once verified we'll push 2.1.0 to CocoaPods master.

ghost commented 10 years ago

Thank you!

I installed HEAD repository and I verified it worked very well. Excellent. We don't need to write codes to make key window and prepare initial view controller on AppDelegate anymore... very smart.

Again, thank you very much for your great work.

jasperblues commented 10 years ago

achievement_unlocked

Thanks again Aleksey !!