Kotlin-Native内存管理:iOS视角与跨Runtime机制解析

Viewed 0

一、前言

本文主要从iOS端的角度探讨Kotlin-Native如何进行内存管理,重点在于厘清Objective-C实例与Kotlin实例的对应关系,以及如何在两个不同的Runtime中适配引用计数和GC的内存回收机制。

1.1 kotlin-native编译

基于Kotlin 2.0.21进行编译和调试(需使用Xcode15),可能会遇到编译报错:

* What went wrong:Could not determine the dependencies of task ':kotlin-stdlib:compileJava9KotlinJvm'.> No matching toolchains found for requested specification: {languageVersion=9, vendor=any, implementation=vendor-specific} for MAC_OS on aarch64.   > No locally installed toolchains match and the configured toolchain download repositories aren't able to provide a match either.

按照官方文档提示执行以下两步操作(新版本已集成到一个sh脚本中):

$ sed -i '' -e '/<components>/,/<\/components>/d' gradle/verification-metadata.xml$ ./gradlew -i --write-verification-metadata sha256,md5 -Pkotlin.native.enabled=true resolveDependencies

若依然报错,通常是因为缺少Java9的工具链,可以在stdlib模块的build.gradle.kts中修改对应Java9的配置。具体的编译指令参照官方文档中的./gradlew对应指令(编译完整版的bundle版本),如需编译带debug信息需加上debug编译参数:

./gradlew -Pshims=true :kotlin-native:bundle -Pkotlin.native.debug=true

编译成功后,可在demo工程的gradle.properties中配置kotlin-native的路径:

kotlin.native.home=/Users/wandawwang/workspace/kmm/kotlin/kotlin-native/dist

1.2 基于CLion代码编辑

官方在kotlin-native/HACKING.md中说明了如何进行编译的预设置,生成编译数据库:

./gradlew :kotlin-native:compdb

然后使用Clion打开kotlin-native项目并配置compile-command.json的路径(编译完成后会在kotlin-native路径下生成)。使用Clion主要是为了在查看代码时获得更好的跳转体验(编译和运行仍需使用上面的gradlew)。

1.3 kotlin-native调试

成功编译kotlin-native后,可以手动创建一个Demo并将kn的路径设置为自己编译的路径(如上节所述)。在AS中编译需要导出的代码:

interface InteroperatAble {
}

/**
 * export
 */
object Interoperator {
    private var bridge: InteroperatAble? = null
    // oc to kotlin
    fun inject(bridge: InteroperatAble?) {
        println("inject")
        this.bridge = bridge
    }
}

在Xcode中编写调用代码:

- (void)viewDidLoad {
    [super viewDidLoad];
    [SharedInteroperator.shared injectBridge:self];
}

此时即可运行代码并查看执行情况。如果使用Android Studio创建的kmp工程,需在toml中将kotlin版本号改为2.0.21,以与自己编译的kotlin-native版本保持一致。

二、Runtime能力注入

从kn源码可以看到一个与Objective-C相关的基础类KotlinBase:

struct ObjHeader;
@interface KotlinBase : NSObject <NSCopying>
+ (instancetype)createRetainedWrapper:(struct ObjHeader *)obj;
- (instancetype)initWithExternalRCRef:(uintptr_t)ref NS_REFINED_FOR_SWIFT;
// Return kotlin.native.ref.ExternalRCRef stored in this class
- (uintptr_t)externalRCRef NS_REFINED_FOR_SWIFT;
@end

该类重写了两个关键方法load和initialize,下面分别说明它们的作用。

2.1 load

load方法在应用程序启动期间由系统自动调用,kn在load方法内部调用了injectToRuntimeImpl函数(使用dispatch_once保证只调用一次):

static void injectToRuntimeImpl() {
  Kotlin_ObjCExport_toKotlinSelector = @selector(toKotlin:);
  Kotlin_ObjCExport_releaseAsAssociatedObjectSelector = @selector(releaseAsAssociatedObject);
}
  1. 将toKotlin方法绑定到Kotlin_ObjCExport_toKotlinSelector全局变量(类型为SEL)。
  2. 将releaseAsAssociatedObject方法绑定到Kotlin_ObjCExport_releaseAsAssociatedObjectSelector全局变量。

2.2 initialize

initialize方法会执行三件事:

  1. 尝试再次调用injectToRuntime方法去绑定toKotlin和release的selector。
  2. 为Block、Bool以及Swift类添加toKotlin和release方法。
  3. 将Kotlin中类型定义的方法和接口绑定到对应的原生类中。

在initialize方法内部,如果当前类型是KotlinBase,也会调用上述的injectToRuntime方法。编译Kotlin代码时,编译器会为添加@ObjCName注解或在iosmain中定义的类/接口进行导出(源码在ObjCExportCodeGenerator.kt中),并将类/接口保存在弱符号变量中:

__attribute__((weak)) const ObjCTypeAdapter** Kotlin_ObjCExport_sortedClassAdapters = nullptr;
__attribute__((weak)) int Kotlin_ObjCExport_sortedClassAdaptersNum = 0;

__attribute__((weak)) const ObjCTypeAdapter** Kotlin_ObjCExport_sortedProtocolAdapters = nullptr;
__attribute__((weak)) int Kotlin_ObjCExport_sortedProtocolAdaptersNum = 0;

这里的__attribute__((weak))表示弱符号,初始值为0。弱符号的特点是如果其他地方有同名的强符号定义,则使用强符号的值;否则使用默认值。导出的类型为ObjCTypeAdapter。

initTypeAdapters会读取编译器在编译时写入的Kotlin_ObjCExport_sortedClassAdapters(class适配器)和Kotlin_ObjCExport_sortedProtocolAdapters(protocol适配器),并写入TypeInfo中缓存起来,以便在按需转换OC实例和Kotlin实例时能找到对应的适配器:

typeInfo->writableInfo_->objCExport.typeAdapter = adapter;

然后为NSBlock、布尔类和Swift对象添加toKotlin:方法,使这些对象可以转换为Kotlin对象:

BOOL added = class_addMethod(nsBlockClass, toKotlinSelector, (IMP)blockToKotlinImp, toKotlinTypeEncoding);
added = class_addMethod(booleanClass, toKotlinSelector, (IMP)boxedBooleanToKotlinImp, toKotlinTypeEncoding);
added = class_addMethod(swiftRootClass, toKotlinSelector, (IMP)SwiftObject_toKotlinImp, toKotlinTypeEncoding);

以及为Swift对象添加releaseAsAssociatedObject方法,用于管理对象的生命周期:

added = class_addMethod(
        swiftRootClass, releaseAsAssociatedObjectSelector,
        (IMP)SwiftObject_releaseAsAssociatedObjectImp, releaseAsAssociatedObjectTypeEncoding
      )

最后,在Kotlin_ObjCExport_initializeClass函数中实现第3点内容。首先将ObjCTypeAdapter的kotlinTypeInfo和Class类型关联起来:

objc_setAssociatedObject(clazz, &associatedTypeInfoKey, value, OBJC_ASSOCIATION_ASSIGN);

然后将Kotlin定义的方法实现指针绑定到该class中,以便后续所有针对该Class或Class实例的方法调用都能找到方法的实现:

// 给class添加实例方法
class_addMethod(clazz, selector, adapter->imp, adapter->encoding);
// 给class添加类方法
class_addMethod(object_getClass(clazz), selector, adapter->imp, adapter->encoding);
// 给class添加协议
addProtocolForInterface(clazz, typeInfo->implementedInterfaces_[i]);

这些是跨Runtime内存管理的准备工作,下面详细分析跨Runtime的内存管理机制。

三、跨Runtime的内存管理

从上面的例子入手,观察跨Runtime的内存管理如何工作。目标在于明确Objective-C实例如何传递到Kotlin侧使用,以及Objective-C实例和对应的Kotlin实例是如何关联的。具体拆分为三个子目标:

  1. 纯OC的实例传递到Kotlin侧。
  2. Kotlin导出到OC的类,在OC侧基于该类生成实例并回传给Kotlin。
  3. Kotlin侧生成类的实例并导出到OC。

3.1 纯OC的实例传递到Kotlin侧

在Objective-C侧调用的injectBridge方法实参self指针是如何传递到Kotlin的?为了探究内部机制,可以查看LLVM IR代码。在build.gradle.kts中配置编译选项以输出IR信息:

iosSimulatorArm64() {
    compilations.all {
        kotlinOptions {
            // 打印gc的信息
            freeCompilerArgs += "-Xruntime-logs=gc=info"
            freeCompilerArgs += "-Xbinary=stripDebugInfoFromNativeLibs=false"
            freeCompilerArgs += "-g"
            freeCompilerArgs += "-verbose"
        }
    }
}

添加verbose选项后,编译时会在输出栏看到相关内容,中间临时IR会输出到临时路径,使用llvm-dis将bc转换为ll文件即可。在Kotlin侧的inject方法内加断点,运行后能看到类似调用栈,其中objc2kotlin_kfun:com.example.kotlinnativedebugdemo.Interoperator#inject方法是理解Objective-C指针如何传递到Kotlin侧的关键。在ll文件中搜索,可以看到类似的方法定义:

define internal void @"objc2kotlin_kfun:com.example.kotlinnativedebugdemo.Interoperator#inject(com.example.kotlinnativedebugdemo.InteroperatAble?){}"(i8* %0, i8* %1, i8* %2) #9 personality i32 (...)* @__gxx_personality_v0 !dbg !2292 {
 ...
}

三个参数分别对应:%0(SharedInteroperator.shared)、%1(injectBridge这个selector)、%2(self指针)。关键代码部分:

entry: ; preds = %stack_locals_init
  %9 = invoke %struct.ObjHeader* @Kotlin_ObjCExport_refFromObjC(i8* %0, %struct.ObjHeader** %6)
          to label %call_success unwind label %fatal_landingpad, !dbg !2293

call_success: ; preds = %entry
  %10 = invoke %struct.ObjHeader* @Kotlin_ObjCExport_refFromObjC(i8* %2, %struct.ObjHeader** %7)
          to label %call_success1 unwind label %fatal_landingpad, !dbg !2293

entry处理SharedInteroperator.shared,call_success处理self。两者都调用了Kotlin_ObjCExport_refFromObjC函数。call_success1随后调用纯Kotlin侧的inject方法:

call_success1: ; preds = %call_success
  invoke void @"kfun:com.example.kotlinnativedebugdemo.Interoperator#inject(com.example.kotlinnativedebugdemo.InteroperatAble?){}"(%struct.ObjHeader* %9, %struct.ObjHeader* %10)
          to label %call_success3 unwind label %kotlinExceptionHandler, !dbg !2293

这表明从Native调用到Kotlin侧,会先调用编译器生成的objc2kotlin_kfun桥接方法,然后在该方法内部调用以kfun为前缀的纯Kotlin方法。

3.1.2 Kotlin_ObjCExport_refFromObjC函数

函数源码如下:

extern "C" ObjHeader* Kotlin_ObjCExport_refFromObjC(id obj, ObjHeader** __result__) {
  kotlin::AssertThreadState(kotlin::ThreadState::kRunnable);
  if (obj == nullptr)  {
      ObjHeader* __obj = nullptr;
      UpdateReturnRef(__result__, __obj);
      return __obj;
  };
  auto msgSend = reinterpret_cast<ObjHeader* (*)(id self, SEL cmd, ObjHeader** slot)>(&objc_msgSend);
  ObjHeader* __result = msgSend(obj, Kotlin_Kotlin_ObjCExport_toKotlinSelector_toKotlinSelector, __result__);
  return __result;
}

如果obj不为nullptr,会执行到Kotlin_ObjCExport_toKotlinSelector,该selector在load方法中绑定到toKotlin方法。通过调试可知,最终会调用NSObject的toKotlin扩展方法,其内部调用Kotlin_ObjCExport_convertUnmappedObjCObject生成Kotlin侧的ObjHeader。

3.1.3 Kotlin_ObjCExport_convertUnmappedObjCObject函数

源码:

extern "C" ObjHeader* Kotlin_ObjCExport_convertUnmappedObjCObject(id obj, ObjHeader** OBJ_RESULT) {
  const TypeInfo* typeInfo = getOrCreateTypeInfo(object_getClass(obj));
  ObjHeader* __result = AllocInstanceWithAssociatedObject(typeInfo, objc_retain(obj), OBJ_RESULT);
  return __result;
}

static const TypeInfo* getOrCreateTypeInfo(Class clazz) {
  const TypeInfo* result = Kotlin_ObjCExport_getAssociatedTypeInfo(clazz);
  if (result != nullptr) {
    return result;
  }
  Class superClass = class_getSuperclass(clazz);
  const TypeInfo* superType = superClass == nullptr ?
    theForeignObjCObjectTypeInfo :
    getOrCreateTypeInfo(superClass);

  std::lock_guard lockGuard(typeInfoCreationMutex);

  result = Kotlin_ObjCExport_getAssociatedTypeInfo(clazz); // double-checking.
  if (result == nullptr) {
    result = createTypeInfo(clazz, superType, nullptr);
    setAssociatedTypeInfo(clazz, result);
  }
  return result;
}

首先获取当前class对应的Kotlin TypeInfo。对于纯OC实例(如MainViewController),无法获取已有的TypeInfo,因此会尝试查找其父类的TypeInfo(依次查找UIViewController、UIResponder和NSObject),最终在运行期间通过createTypeInfo为OC实例创建对应的TypeInfo,并将其添加到类的关联对象中缓存。然后基于TypeInfo创建实例:

inline static ObjHeader* AllocInstanceWithAssociatedObject(const TypeInfo* typeInfo, id associatedObject, ObjHeader** OBJ_RESULT) {
  ObjHeader* result = AllocInstance(typeInfo, OBJ_RESULT);
  SetAssociatedObject(result, associatedObject);
  return result;
}

调用AllocInstanceWithAssociatedObject传入的参数是objc_retain(obj)的返回值,这意味着纯OC的实例传递到Kotlin侧会使其引用计数+1。在Kotlin侧无论增加多少次引用,均不会继续增加该实例的引用计数。

总结:纯OC的实例会调用NSObject的toKotlin扩展方法,内部调用Kotlin_ObjCExport_convertUnmappedObjCObject生成Kotlin侧的ObjHeader。引用计数方面,纯OC实例传递到Kotlin侧会使其引用计数+1,后续Kotlin侧的引用不会增加该计数。

3.2 OC侧生成Kotlin类的实例并传递到Kotlin侧

修改Demo,将Kotlin类导出到OC侧并在OC侧生成该类的实例,然后回传给Kotlin。Kotlin代码:

class Kotlin2Objc:InteroperatAble {
}

OC代码:

SharedKotlin2Objc *k2o = [[SharedKotlin2Objc alloc] init];
[SharedInteroperator.shared injectBridge:k2o];

调试可知alloc会走到KotlinBase的allocWithZone方法,关键内存管理部分代码:

KotlinBase* result = [super allocWithZone:zone];
ObjHolder holder;
AllocInstanceWithAssociatedObject(typeInfo, result, holder.slot());
result->refHolder.initAndAddRef(holder.obj());

result是Native侧生成的OC对象,holder.slot()是调用AllocInstanceWithAssociatedObject返回的Kotlin侧实例(ObjHeader)。result会被添加到Kotlin侧实例的AssociatedObject内部保留。这里AllocInstanceWithAssociatedObject调用时没有执行objc_retain来增加引用计数。

refHolder是KotlinBase内部的一个成员变量BackRefFromAssociatedObject refHolder;,其作用是维护从OC对象到Kotlin对象的反向引用,提供引用计数机制,确保只要关联对象存在,Kotlin对象就不会被垃圾回收。这保证了生命周期的同步。

BackRefFromAssociatedObject定义中包含引用计数管理。initAndAddRef和createObjCBackRef操作ref_成员变量,生成Node实例并放入队列中,Node持有ObjHeader。后续的retain和release基于Node的rc_成员操作。

在OC侧生成Kotlin类的实例时,编译器会基于Framework前缀生成一个类似SharedBase的类,其方法、成员变量等信息都指向KotlinBase类。在toKotlin方法中,通过refHolder.ref()获取Kotlin侧的ObjHeader实例,该实例是Node中持有的obj_成员。

内存管理方面,KotlinBase重写了retain和release方法,非持久化对象的retain和release调用refHolder对应的函数,这是kn-runtime自己管理的引用计数,不会影响OC的原生引用计数。因此,无论增加多少引用关系,通过CFGetRetainCount得到的引用计数始终为1,这与纯OC实例的引用计数表现不同。

OC实例通过Node反向引用Kotlin的ObjHeader,Node被添加到GC根的特殊引用指针单向链表中,确保Kotlin对象不会被回收。

3.3 Kotlin侧生成类的实例导出到OC并回传到Kotlin侧

修改Demo,在Kotlin侧生成实例并传递到OC侧。Kotlin代码:

class Kt2OC2Kt: InteroperatAble {
}
object Interoperator {
    private var k2o2k: Kt2OC2Kt? = Kt2OC2Kt()
    fun getKotlinInstance(): Kt2OC2Kt? {
        return k2o2k
    }
    fun inject(bridge: InteroperatAble?) {}
}

关键问题:Kotlin侧生成的实例如何转换为OC侧可以使用的实例?从LLVM IR代码分析,objc2kotlin_kfun函数中调用Kotlin_ObjCExport_refToRetainedObjC将Kotlin实例转换为OC实例。

3.3.1 Kotlin_ObjCExport_refToRetainedObjC

函数定义:

extern "C" id Kotlin_ObjCExport_refToRetainedObjC(ObjHeader* obj) {
  return Kotlin_ObjCExport_refToObjCImpl<true>(obj);
}

内部实现会尝试从TypeInfo中获取缓存的转换函数,如果不存在则走slowpath,最终调用createRetainedWrapper方法。

3.3.2 createRetainedWrapper方法

对于非持久化对象,简化后的源码:

+ (instancetype)createRetainedWrapper:(ObjHeader*)obj {
    KotlinBase* candidate = [super allocWithZone:nil];
    bool permanent = obj->permanent();
    candidate->permanent = permanent;
    candidate->refHolder.initAndAddRef(obj);
    if (!isShareable(obj)) {
      SetAssociatedObject(obj, candidate);
    } else {
      id old = AtomicCompareAndSwapAssociatedObject(obj, nullptr, candidate);
      if (old != nullptr) {
        {
          kotlin::ThreadStateGuard guard(kotlin::ThreadState::kNative);
          candidate->refHolder.releaseRef();
          [candidate releaseAsAssociatedObject];
        }
        return objc_retain(old);
      }
    }
    return candidate;
}

由于isShareable在当前版本固定返回true,因此会执行AtomicCompareAndSwapAssociatedObject原子性地交换obj的关联对象。如果关联对象为nullptr,则将candidate写入关联对象;如果不为nullptr,则返回现有关联对象并释放candidate,同时增加引用计数。

对于Kotlin中声明的类实例,objc_retain不会增加OC定义中的引用计数,因为KotlinBase重写了retain方法,只增加kotlin-native自己维护的引用计数。

四、WeakReference实现

使用弱引用的目的是:新对象不影响原对象的释放时机;原对象释放后新对象能被正常置空。Kotlin中WeakReference的定义表明创建弱引用实例调用Konan_getWeakReferenceImpl函数,分为三类实现:永久对象、Objective-C对象和Kotlin对象。

4.1 Permanent对象

对于长生命周期对象(如字符串常量和全局常量),WeakReference就是该对象自身,不影响生命周期且不会置空。

4.2 Objective-C对象

所有NSObject的子类,无论是Kotlin创建还是OC代码创建的实例,都视为Objective-C对象。弱引用实现基于OC的__weak机制,通过KotlinObjCWeakReference实例存储弱引用。读取时从OC的weak表中获取原对象指针,转换为Kotlin实例。该引用不会增加原OC实例的引用计数,原对象释放时weak指针会被置空。

4.3 Kotlin对象

弱引用实现通过createRegularWeakReferenceImpl函数,首先尝试获取已存在的weakRef,如果不存在则新创建。弱引用保存在ExtraObjectData的weakReferenceOrBaseObject_成员中,使用指针低位标记。无论生成多少个弱引用实例,只会生成一个impl实例。在垃圾回收过程中,如果对象没有强引用,RegularWeakReferenceImpl内部的引用会被置为null,通过弱引用访问时得到null。

五、后记

从以上内容可以大致了解在iOS中kotlin-native管理内存的方式。目前没有自动检测Kotlin/Native代码中保留循环的工具,因此如果发现内存泄漏,需要在Native使用weak或在Kotlin使用WeakReference来断开引用链条。

0 Answers