iOS原生内嵌Cocos最佳实践

2,207 阅读6分钟

设计新架构目的

Cocos官方并未提供iOS原生内嵌Cocos方案,目前大多采用的方案是:

Cocos编辑器生成iOS工程,以该iOS工程作为基底,再做功能开发。该方案有以下几个问题:

  1. 代码层:Cocos和原生通信代码都在主工程中,随着后续功能迭代,主工程会越来越臃肿,不方便后续维护管理。
  2. 团队协作:开发者必须从Cocos编辑器中生成iOS工程,在对工程配置后,才能开发,开发效率极低。
  3. 工程侵入性:在已有的iOS项目中,几乎无法内嵌Cocos游戏,只能以web形式内嵌,大大降低游戏性能、体验。

新架构特性

针对上述方案的缺点, 新架构应满足以下几点特性:

  1. iOS工程基于原生生成,Cocos引擎以pods库形式被引用。
  2. Cocos与原生通信代码与主工程解耦,保持主工程代码整洁。
  3. 团队成员通过pods命令可以直接生成、运行项目。

新架构之路

编译Cocos引擎库

通过Cocos编辑器找到Cocos引擎源码,引擎源码目录:/Applications/Cocos/Creator/3.7.4/CocosCreator.app/Contents/Resources/resources/3d/engine/native/

1.png

生成cocos引擎需要以下几个文件及目录

CMakeLists.txt
cmake/
cocos/
extensions/
external/
templates/
utils/CMakeLists.header.txt

通过CMake指令即可生成Cocos引擎工程,CMake指令:

#!/bin/bash

mkdir -p build
cp CMakeLists.header.txt  build/CMakeLists.txt
cmake -Sbuild -Bbuild -DCC_USE_GLES2=OFF -DCC_USE_VULKAN=OFF -DCC_USE_GLES3=OFF -DCC_USE_METAL=ON \
    -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
    -DUSE_PHYSICS_PHYSX=ON \
    -DCMAKE_OSX_SYSROOT=iphoneos \
    -DCMAKE_SYSTEM_NAME=iOS \
    -GXcode
if [[ -f build/compile_commands.json ]]; then
   cp build/compile_commands.json .
fi

这里只需要生成iOS工程,修改了部分配置文件,已上传github仓库,可直接使用。

Cocos引擎封装

接下来在主工程中引用引擎库,但是使用引擎方法会出现找不到头文件错误。而要直接引用头文件,需要找到引擎中所有头文件,并且要按Cocos的目录存放,这工作量相当大。所以对引擎做一层封装,主工程只要依赖封装的类,即可实现对引擎方法的调用。

分析Cocos生成的iOS工程,看看iOS工程主要调用了哪些方法:

  1. 初始化(iOS工程-main.m)

    cc::BasePlatform* platform = cc::BasePlatform::getPlatform();
    if (platform->init()) {
        return;
    }
    
  2. 应用生命周期(iOS工程-AppDelegate.mm)

    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions;
    - (void)applicationWillResignActive:(UIApplication *)application;
    - (void)applicationDidBecomeActive:(UIApplication *)application;
    - (void)applicationWillTerminate:(UIApplication *)application;
    - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator
    
  3. 创建Cocos页面(iOS工程-AppDelegate.mm)

    CGRect bounds = [[UIScreen mainScreen] bounds];
    self.window   = [[UIWindow alloc] initWithFrame:bounds];
    
    // Should create view controller first, cc::Application will use it.
    _viewController                           = [[ViewController alloc] init];
    _viewController.view                      = [[View alloc] initWithFrame:bounds];
    _viewController.view.contentScaleFactor   = UIScreen.mainScreen.scale;
    _viewController.view.multipleTouchEnabled = true;
    [self.window setRootViewController:_viewController];
    
    [self.window makeKeyAndVisible];
    
    • 初始化应用window(Cocos引擎通过UIApplication.shared.delegate.window获取应用window,执行渲染操作)。
    • 创建window的ViewController,并对ViewController的View赋值,赋值对象是引擎的cc.View
  4. cc.View的宿主ViewController实现转屏方法(iOS工程-ViewController.mm)

    AppDelegate* delegate = [[UIApplication sharedApplication] delegate];
    [delegate.appDelegateBridge viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
    float pixelRatio = [delegate.appDelegateBridge getPixelRatio];
    
    //CAMetalLayer is available on ios8.0, ios-simulator13.0.
    CAMetalLayer *layer = (CAMetalLayer *)self.view.layer;
    CGSize tsize             = CGSizeMake(static_cast<int>(size.width * pixelRatio),
                                         static_cast<int>(size.height * pixelRatio));
    layer.drawableSize = tsize;
    
  5. cc.BaseGame类实现(iOS工程-Game.h)

#pragma once

#include "cocos/cocos.h"

/**
 @brief    The cocos2d Application.

 The reason for implement as private inheritance is to hide some interface call
 by Director.
 */
class Game : public cc::BaseGame {
public:
  Game();
  int init() override;
  // bool init() override;
  void onPause() override;
  void onResume() override;
  void onClose() override;
};

该类无需对iOS工程暴露,实现方法即可。

通过创建cocos_bridge库,封装上述用到的方法,iOS工程依赖cocos_bridge即可实现对引擎调用。

#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>

@interface xx_cocos_bridge : NSObject

+ (xx_cocos_bridge *)instance;

- (void)initPlatform;
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions;
- (void)applicationWillResignActive:(UIApplication *)application;
- (void)applicationDidBecomeActive:(UIApplication *)application;
- (void)applicationWillTerminate:(UIApplication *)application;
- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator;
- (UIView*)getCocosView;
- (float)getCocosPixelRatio;

+ (void)callS:(NSString *)funcName args:(NSString *)args;

@end

在cocos引擎工程中创建cocos_bridge库,实现上述方法,配置头文件路径、编译选项,可编译出独立的封装库。

到此你可以得到[lib_engine.a, lib_cocos_bridge.a, coco_bridge.h]。因为Cocos引擎还依赖了第三方库,所以以下第三方库也要一并加进来(/external/ios/libs/*)。接下来解决通过pods引用这些静态库。 2.png

制作Cocos引擎Pods库

通过制作pod库方便iOS主工程依赖Cocos引擎以及引擎封装类,该库需要包含封装类的头文件,以及Cocos静态库和Cocos的依赖三方库

  1. pod库的Classes文件下放封装类头文件 3.png

  2. pod库的Classes文件下放静态库文件 libcocos_engine.a是编译出来的引擎静态库,libkk_cocos.a是封装类的静态库,其他静态库都是Cocos引擎依赖的三方静态库。 4.png

  3. podspec文件配置 frameworks和library是从Cocos的demo工程中找出来的,需要全部加上。

s.source_files = 'kk_cocos_game/Classes/**/*.h'

s.vendored_libraries = "kk_cocos_game/Libs/**/*.a"

s.frameworks = 'AudioToolbox', 'AVFoundation', 'AVKit', 'CoreVideo', 'CoreMotion', 'CFNetwork', 'CoreMedia', 'CoreText', 'CoreGraphics', 'GameController', 'JavaScriptCore', 'Metal', 'MetalKit', 'MetalPerformanceShaders', 'OpenAL', 'OpenGLES', 'QuartzCore', 'SystemConfiguration', 'Security', 'UIKit', 'WebKit'

s.library = 'sqlite3', "iconv", "z"

至此iOS主工程只要需要通过pod命令即可轻松依赖Cocos引擎,接下来详解主工程中如何显示Cocos场景以及场景切换。

Cocos与原生界面管理

场景切换

Cocos画面渲染需要用到AppDelegate下的window,所以iOS13后SceneDelegate方案暂时还不能用。 Cocos与原生界面切换本质就是通过切换各自持有的window显示/隐藏实现。 因此在AppDelegate中需要声明两个window,代码如下:

@interface AppDelegate()

// 新声明的原生window,本项目用的是Flutter页面
@property (nullable, nonatomic, strong) UIWindow *flutterWindow;
@property (nonatomic, strong) FlutterEngine *flutterEngine;

@end

@implementation AppDelegate

@synthesize window;

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary<UIApplicationLaunchOptionsKey,id> *)launchOptions {
    
    // 初始化游戏引擎
    [[kk_cocos_bridge instance] initPlatform];
    
    window = [[UIWindow alloc]initWithFrame:[UIScreen mainScreen].bounds];
		// 创建game window的ViewController
    GameViewController *vc = [[GameViewController alloc] init];
		// ViewController的view属性需要使用Cocos的View赋值
    vc.view = [[kk_cocos_bridge instance] getCocosView];
    window.rootViewController = vc;
    [window makeKeyAndVisible];
    
		// Flutter初始化,如果只用原生页面,此处不用考虑
    _flutterEngine = [[FlutterEngine alloc] initWithName:@"com.xxx.flutter-engine"];
    [_flutterEngine run];
    [GeneratedPluginRegistrant registerWithRegistry:_flutterEngine];
    
    _flutterWindow = [[UIWindow alloc]initWithFrame:[UIScreen mainScreen].bounds];
    FlutterViewController *flutterVC = [[FlutterViewController alloc]initWithEngine:_flutterEngine nibName:nil bundle:nil];
    _flutterWindow.rootViewController = flutterVC;
    
		// 优先显示Flutter场景
    [self.flutterWindow makeKeyAndVisible];
    
    [[kk_cocos_bridge instance] application:application didFinishLaunchingWithOptions:launchOptions];
    
    return YES;
}

再添加两个切换窗口显示的方法,就基本完成Cocos界面控制。

不过从工程架构看,该方案的侵入性还是不友好,还需要对AppDelegate解耦,做一套组件化方案。

AppDelegate组件化

通过对网上的方案比较,决定采用TDFModuleKit方案(实现原理作者原文)

  1. 创建一个pod库,该库依赖TDFModuleKit,管理window生命周期,负责window切换工作。
// XXWindowModule.h
// XXWindowModule 继承TDFModule
@interface XXWindowModule: TDFModule<TDFModuleProtocol>

@end

// XXWindowModule.m
// XXWindowModule需要再load阶段调用registerModule
+ (void)load {
    [self registerModule];
}

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary<UIApplicationLaunchOptionsKey,id> *)launchOptions {
    
		// 把主工程的代码块迁移到该模块下即可.
    ...
    
    return YES;
}

2. window切换方法

- (void)showGameWindow:(NSNumber*)landscape {
    dispatch_async(dispatch_get_main_queue(), ^{
        self.currentWindowType = @"Game";
        [self.window makeKeyAndVisible];
        [self updateOrientation:landscape.longLongValue == 1 ? UIInterfaceOrientationMaskLandscape : UIInterfaceOrientationMaskPortrait];
    });
}

// 本项目原生页面用Flutter替代
- (void)showFlutterWindow {
    dispatch_async(dispatch_get_main_queue(), ^{
        self.currentWindowType = @"Flutter";
        [self updateOrientation:UIInterfaceOrientationMaskPortrait];
        [self.flutterWindow makeKeyAndVisible];
    });
}

3. 方法调用

通过performSelector即可完成切换window方法调用

UIApplication.shared.delegate?.perform(Selector(("showGameWindow:")),with: landscape)

游戏资源

目前采取比较粗暴的方式,直接把Cocos打包的游戏资源,拖入到iOS主工程中。

总结

至此基本完成iOS内嵌Cocos架构,能满足基础的原生与Cocos场景切换。如果还有精力,可以对Cocos引擎层代码再做一些修改(比如使用SceneDelegate方案、游戏资源管理)。

鉴于网上关于Cocos与原生开发的技术文章还是太少,因此记录下本次实践记录,可以给更多开发者一个实践参考。如有更好方案或疑问欢迎评论区交流。