likes
comments
collection
share

当纯Flutter开发遇到iOS的weak属性

作者站长头像
站长
· 阅读数 6

背景

我们是一个纯Flutter应用,开机屏使用了穿山甲的广告来增加收入。当集成一个公司内部的原生组件之后,发现iOS端显示开机屏广告之后直接黑屏。

由于我们一直是纯Flutter应用,所以与原生端的交互都是通过plugin的方式。所以在做开机屏广告的时候,也要求借调过来的同学把穿山甲sdk封装成独立的plugin

问题原因

由于在没有集成公司原生sdk的时候,app在显示广告之后是能正常展示的,所以第一时间查看了该sdk对app的改动,发现该组件将rootViewController修改为了UINavigatorController。代码如下:

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    GeneratedPluginRegistrant.register(with: self)
    let res = super.application(application, didFinishLaunchingWithOptions: launchOptions)
   
      let rootVc = self.window?.rootViewController
      if rootVc != nil && !(rootVc is UINavigationController) {
          let navController = UINavigationController(rootViewController: rootVc!)
          navController.navigationBar.isHidden = true
          self.window?.rootViewController = navController
      }
      return res
  }

之所以这么做是因为SDK的页面跳转方式是通过导航控制器的,但是在纯Flutter开发中,根控制器是FlutterViewController,其继承自UIViewController, 为了能正常跳转处置页面,这里将根控制器手动改为了UINavigationController

那么之前是怎么显示开机屏广告以及怎么关闭广告的呢?看了下插件代码,大致如下

// 开屏广告
- (void) showSplashAd:(FlutterMethodCall*) call result:(FlutterResult) result{
    UIWindow *mainView =[[UIApplication sharedApplication] keyWindow];
    UIViewController *tmpRootVc = mainView.rootViewController;
    DJWelcomeViewController *vc = [[DJWelcomeViewController alloc] init];
    vc.view.frame = CGRectMake(0, 0, [[UIScreen mainScreen] bounds].size.width, [[UIScreen mainScreen] bounds].size.height);
    mainView.rootViewController = vc;
    vc.tmpRootVc = tmpRootVc;
    result(@"");
}

就是将原来的rootVc保存一下,将window的根控制器设置为广告的控制器来显示开机屏。

下面是关闭广告代码:

UIWindow *mainView =[[UIApplication sharedApplication] keyWindow];
mainView.rootViewController = self.tmpRootVc;

看起来没有什么问题,切换根控制器也是iOS的一个常见操作,那为什么突然就不好使了呢?

首先我排查了下是不是根控制器的view没有设置背景色,在切换的时候将根控制器view又设置了背景色为白色,重新运行,依然还是黑屏。说明不是背景色的问题。

那另一种常见的黑屏原因可能是根控制器出了问题,直接Xcode运行项目,当黑屏时查看视图层级如下: 当纯Flutter开发遇到iOS的weak属性

发现window下根本就没有控制器!断点查看: 当纯Flutter开发遇到iOS的weak属性

发现rootVc已经为nil,所以导致app黑屏。查看了下rootVc的声明:

@property(nonatomic, weak) UIViewController *rootVc;	

发现rootVc是由weak修饰。这里就真相大白了,因为weak 表示的是一种非拥有关系,当首次切换根控制器为DJWelcomeViewController 时,作为根控制器的UINavigatorController 的不再被window持有而释放,DJWelcomeViewController持有的rootVc属性也随之置为nil。当再次切换时window就不再有根视图而显示黑屏。

解决

知道了是什么原因,那解决起来就比较简单了。这里有两种解决方案:

方案一:

rootVc 改为strong修饰,让DJWelcomeViewController 强持有原本的根控制器,这样当关闭广告时可以顺利拿到根控制器

@property(nonatomic, strong) UIViewController *rootVc;

方案二:

还有一种方案就是不再通过切换rootViewController的方式来显示/关闭广告,而使用push或者present的方式路由到广告页面,

// 开屏广告
- (void)showSplashAd:(FlutterMethodCall*)call result:(FlutterResult) result{
    UIWindow *window =[[UIApplication sharedApplication] keyWindow];
    UIViewController *rootVc = window.rootViewController;
    DJWelcomeViewController *vc = [[DJWelcomeViewController alloc] init];
  // 使用push或者present 跳转
    if([rootVc isKindOfClass:[UINavigationController class]]) {
        UINavigationController *nav = (UINavigationController *)rootVc;
        [nav pushViewController:vc animated:NO];
    } else {
        vc.modalPresentationStyle = UIModalPresentationFullScreen;
        [rootVc presentViewController:vc animated:NO completion:nil];
    }
    result(@"");
}

关闭广告

    // 返回flutter侧
    if(self.navigationController) {
        [self.navigationController popViewControllerAnimated:NO];
    } else {
        [self dismissViewControllerAnimated:NO completion:nil];
    }

可以看到,方案一的改动很小,只需要将weak改为strong, 其他都不用改就能解决问题。而方案二呢则是改变了展示广告的方式,显示、关闭广告的方法都要修改,看起来改动代码比较多。

但是由于我们一直是纯Flutter开发,所以希望能尽量少的对根控制器进行操作以避免一些可能的未知问题,所以最后采用了方案二来解决。

思考

到这里问题是已经解决了,但却还有一个疑问:

为什么之前使用weak修饰没有问题,将根控制器修改为UINavigationController 之后就有了问题?

其实在iOS原生开发中,在切换控制器时通常是重新创建一个新的vc来设置为rootViewController。如果想像上面那样持有原来的根控制器的话,就需要对其进行一个强持有,使用weak的话显示黑屏才是正常的。

所以问题可以改为

为什么FlutterViewControllerweak修饰,在切换了根控制器之后,FlutterViewController 为什么还没有被释放?

app启动后,iOS端会初始化AppDelegate,纯Flutter开发中,AppDelegate 继承自FlutterAppDelegate ,下面是该类的部分实现:

// 初始化
- (instancetype)init {
  if (self = [super init]) {
    _lifeCycleDelegate = [[FlutterPluginAppLifeCycleDelegate alloc] init];
  }
  return self;
}

- (BOOL)application:(UIApplication*)application
    willFinishLaunchingWithOptions:(NSDictionary*)launchOptions {
  return [_lifeCycleDelegate application:application willFinishLaunchingWithOptions:launchOptions];
}

- (BOOL)application:(UIApplication*)application
    didFinishLaunchingWithOptions:(NSDictionary*)launchOptions {
  return [_lifeCycleDelegate application:application didFinishLaunchingWithOptions:launchOptions];
}

// Returns the key window's rootViewController, if it's a FlutterViewController.
// Otherwise, returns nil.
- (FlutterViewController*)rootFlutterViewController {
  if (_rootFlutterViewControllerGetter != nil) {
    return _rootFlutterViewControllerGetter();
  }
  UIViewController* rootViewController = _window.rootViewController;
  if ([rootViewController isKindOfClass:[FlutterViewController class]]) {
    return (FlutterViewController*)rootViewController;
  }
  return nil;
}

可以看到,在初始化的时候创建了一个_lifeCycleDelegate,其主要作用是代理一些app的生命周期方法。

这里我们可以看下rootFlutterViewController 方法,可以看到在获取rootViewController 时判断了下类型是不是FlutterViewController ,如果不是,直接返回nil。

显然,当我们将rootViewController设置为UINavigatorController时,rootFlutterViewController 会返回nil。这也就解释了上面的疑问。当将rootViewControllerFlutterViewController 切换走之后,FlutterViewController并不会释放,Flutter框架依然持有着FlutterViewController

其他

这里简单介绍一下FlutterViewController

FlutterViewController 依附于 FlutterEngine,给 Flutter 传递 UIKit 的输入事件,并展示被 FlutterEngine 渲染的每一帧画面。FlutterEngine 则充当 Dart VM 和 Flutter 运行时的主机。

具体的可以看下flutter.cn/docs/develo…

FlutterViewController提供了一个iOS端的容器,FlutterEngine 负责UI的绘制。

FlutterViewController有两个构造函数:

一:传递一个FlutterEngine作为参数

- (instancetype)initWithEngine:(FlutterEngine*)engine
                       nibName:(nullable NSString*)nibName
                        bundle:(nullable NSBundle*)nibBundle {
  NSAssert(engine != nil, @"Engine is required");
  self = [super initWithNibName:nibName bundle:nibBundle];
  if (self) {
    _viewOpaque = YES;
    if (engine.viewController) {
      FML_LOG(ERROR) << "The supplied FlutterEngine " << [[engine description] UTF8String]
                     << " is already used with FlutterViewController instance "
                     << [[engine.viewController description] UTF8String]
                     << ". One instance of the FlutterEngine can only be attached to one "
                        "FlutterViewController at a time. Set FlutterEngine.viewController "
                        "to nil before attaching it to another FlutterViewController.";
    }
    _engine.reset([engine retain]);
    _engineNeedsLaunch = NO;
    _flutterView.reset([[FlutterView alloc] initWithDelegate:_engine opaque:self.isViewOpaque]);
    _weakFactory = std::make_unique<fml::WeakPtrFactory<FlutterViewController>>(self);
    _ongoingTouches.reset([[NSMutableSet alloc] init]);

    [self performCommonViewControllerInitialization];
    [engine setViewController:self];
  }

  return self;
}

构造函数二:内部会创建一个FlutterEngine

- (instancetype)initWithProject:(FlutterDartProject*)project
                   initialRoute:(NSString*)initialRoute
                        nibName:(NSString*)nibName
                         bundle:(NSBundle*)nibBundle {
  self = [super initWithNibName:nibName bundle:nibBundle];
  if (self) {
    [self sharedSetupWithProject:project initialRoute:initialRoute];
  }

  return self;
}

- (void)sharedSetupWithProject:(nullable FlutterDartProject*)project
                  initialRoute:(nullable NSString*)initialRoute {
  // Need the project to get settings for the view. Initializing it here means
  // the Engine class won't initialize it later.
  if (!project) {
    project = [[[FlutterDartProject alloc] init] autorelease];
  }
  FlutterView.forceSoftwareRendering = project.settings.enable_software_rendering;
  auto engine = fml::scoped_nsobject<FlutterEngine>{[[FlutterEngine alloc]
                initWithName:@"io.flutter"
                     project:project
      allowHeadlessExecution:self.engineAllowHeadlessExecution
          restorationEnabled:[self restorationIdentifier] != nil]};

  if (!engine) {
    return;
  }

  _viewOpaque = YES;
  _weakFactory = std::make_unique<fml::WeakPtrFactory<FlutterViewController>>(self);
  _engine = std::move(engine);
  _flutterView.reset([[FlutterView alloc] initWithDelegate:_engine opaque:self.isViewOpaque]);
  [_engine.get() createShell:nil libraryURI:nil initialRoute:initialRoute];
  _engineNeedsLaunch = YES;
  _ongoingTouches.reset([[NSMutableSet alloc] init]);
  [self loadDefaultSplashScreenView];
  [self performCommonViewControllerInitialization];
}

FlutterEngine 会通过setViewController方法指定一个FlutterViewController :

- (void)setViewController:(FlutterViewController*)viewController {
  FML_DCHECK(self.iosPlatformView);
  _viewController =
      viewController ? [viewController getWeakPtr] : fml::WeakPtr<FlutterViewController>();
  self.iosPlatformView->SetOwnerViewController(_viewController);
  [self maybeSetupPlatformViewChannels];

  if (viewController) {
    __block FlutterEngine* blockSelf = self;
    self.flutterViewControllerWillDeallocObserver =
        [[NSNotificationCenter defaultCenter] addObserverForName:FlutterViewControllerWillDealloc
                                                          object:viewController
                                                           queue:[NSOperationQueue mainQueue]
                                                      usingBlock:^(NSNotification* note) {
                                                        [blockSelf notifyViewControllerDeallocated];
                                                      }];
  } else {
    self.flutterViewControllerWillDeallocObserver = nil;
    [self notifyLowMemory];
  }
}

PS: 这里只是分析了纯Flutter的应用,对于add-to-app形式可能会有些不同的情况。

另:其实我觉得sdk应该暴露api给调用方,让调用方自己决定如何跳转到处置页面,而不是要求调用方必须有UINavigatorController,但这是集团内部的sdk,也没办法要求人家更改,只能自己处理了😮‍💨

由于是个人的分析,难免会有些遗漏或者错误之处,还望各位大佬能多多指教