Kotlin 与 Java 互操作:混合工程里的平台类型与 API 边界

文章目录

  • 第 1 章 混合工程的第一道墙:平台类型与 API 边界
      • 原理补充
      • 踩坑
      • 动手练
  • 第 2 章 @JvmStatic、@JvmField、@JvmOverloads:Java 能调到的形状
      • 原理补充
      • 踩坑
      • 动手练
  • 第 3 章 SAM 转换与函数类型:lambda 在 Java 侧的落点
      • 原理补充
      • 踩坑
      • 动手练
  • 第 4 章 @Nullable、@NotNull 与跨语言 null 契约
      • 原理补充
      • 踩坑
      • 动手练
  • 第 5 章 suspend 的 Java 视角:Continuation 与调用边界
      • 原理补充
      • 踩坑
      • 动手练
  • 第 6 章 API 边界治理:模块分层与混编反模式
      • 动手练
  • 面试速查 · 追问链
      • 追问链 #1:什么是平台类型 `T!`? 🔥
      • 追问链 #2:@JvmStatic、@JvmField、@JvmOverloads 分别解决什么? 🔥
      • 追问链 #3:Kotlin lambda 什么情况下 Java 不能用? ⭐
      • 追问链 #4:Java 能直接调用 suspend 函数吗? ⭐
      • 追问链 #5:@Nullable 和 Kotlin `T?` 怎么配合? 💡
  • 完整链路一句通
  • 相关推荐

第 1 章 混合工程的第一道墙:平台类型与 API 边界

Gradle 里kotlinjava源码目录并存时,真正容易出线上事故的不是「语法差异」,而是类型契约在语言边界上断裂。Kotlin 编译器对 Java 声明的类型无法完全推断可空性,于是产生平台类型(platform type),在 IDE 里常写作T!——对 Kotlin 而言「可空也可非空」,编译器不强制你处理 null,但 JVM 运行时照样NullPointerException

典型边界有三类:

  1. Android Framework / JetpackBundle.getString()SavedStateHandle.get<T>()等 Java 或带平台类型的 API。
  2. 三方 Java SDK:Retrofit 接口、OkHttpCallback、旧版 Gson 模型。
  3. 团队遗留 Java 模块:Kotlin 新代码调用 JavaUtils,或 Java Activity 调用 KotlinViewModel工厂。

工程原则:在边界收口,不在 UI 深处赌运气。从 Java 进来的值,在 Repository / ViewModel 构造或映射层立刻收窄为TT?;向外暴露给 Java 的 Kotlin API,用注解与@Jvm*把契约写进字节码。

// Java 侧声明:String getName();// Kotlin 读到的是 String! —— 必须主动决策funfromJavaBundle(bundle:Bundle):String{valraw:String?=bundle.getString("title")// 显式收窄为 String?returnraw?:""}

ViewModel 从SavedStateHandle读取导航参数,是 Android 工程里平台类型边界的标准样本:

private val category: Category? = savedStateHandle.get<String>("category")?.let { name -> runCatching { Category.valueOf(name) }.getOrNull() }

链路解读get<String>在 Kotlin 侧为平台类型边界;?.let把「可能缺失」写成Category?runCatching处理非法枚举名。若写成get<String>("category")!!Category.valueOf(...)裸调,Java 边界上的 null 或脏数据会直接变成崩溃。

原理补充

平台类型只存在于Kotlin 源码对 Java 声明的引用;同一符号在 Java 文件里就是普通T或带@NullableT。K2 编译器仍按相同规则生成 JVM 签名,互操作语义未变,差异主要在类型检查与 IDE 提示精度。

踩坑

  • String!String一路传到 UI,省略边界校验。
  • 在 Java 公共 API 返回 null 却不标@Nullable,Kotlin 调用方无法获得可空提示。
  • 混编模块「默认都能互相调」——未划分谁负责收窄平台类型

动手练

  • A:用一句话说明String!String?在编译器检查上的区别。
  • D:在所在工程搜索getStringSavedStateHandle.get,找 1 处并判断:若去掉?.谁会先 NPE。

第 2 章 @JvmStatic、@JvmField、@JvmOverloads:Java 能调到的形状

Kotlin 的objectcompanion object、默认参数、顶层函数,编译到 JVM 后与 Java 开发者直觉不完全一致。三类注解负责把** Kotlin 写法翻译成Java 调用面**。

注解解决的问题Java 调用侧效果
@JvmStaticcompanion/object成员默认是实例方法生成真正静态方法
@JvmFieldKotlinval/var生成 getter/setter暴露为 public 字段
@JvmOverloads默认参数只生成一个完整签名生成多重重载,逐参省略

@JvmStatic:Java 侧Logger.d式工具类、单例工厂常用。

objectNetworkDefaults{constvalCONNECT_TIMEOUT_SEC=10@JvmStaticfuntag(module:String):String="AppNet/$module"}classApiFactoryprivateconstructor(){companionobject{@JvmStaticfuncreate(baseUrl:String):Retrofit=/* ... */}}

Java 调用:NetworkDefaults.tag("home")ApiFactory.create(url)。无@JvmStatic时 Java 需写NetworkDefaults.INSTANCE.tag(...),团队混编库几乎不可接受。

@JvmField:Parcelable 风格常量、与 Java 框架反射读字段的场景;不要滥用——破坏封装时优先@JvmStaticgetter。

classEventBusTags{@JvmFieldvalrefreshList:String="refresh_list"}

@JvmOverloads:Kotlin 默认参数在 JVM 上只保留「全参」构造;Java 无法省略尾部参数。

classImageLoader@JvmOverloadsconstructor(context:Context,privatevaldiskCacheMb:Int=50,privatevalcrossfade:Boolean=true,){@JvmOverloadsfunload(url:String,placeholder:Int=R.drawable.placeholder){/* ... */}}

Java 可new ImageLoader(ctx)loader.load(url);Kotlin 仍享受单一定义。

原理补充

@JvmOverloads生成重载顺序从右向左省略默认参数;与@JvmName配合可解决签名冲突(同名擦除),本篇不展开。const val已是编译期常量,Java 用NetworkDefaults.CONNECT_TIMEOUT_SEC直接访问,无需@JvmField

踩坑

  • data class主构造加@JvmOverloads却不考虑copy与 Java Bean 工具链的期望形状。
  • @JvmField暴露可变var,Java 任意改写破坏不可变模型。
  • companion里只有@JvmStatic方法、无const,Java 仍要类名前缀——与 Javastatic工具类习惯不一致时漏注解。

动手练

  • Acompanion objectfun parse(json: String)要给 Java 静态调用,加哪条注解?
  • B(起点):下面 Java 调用编译失败,补注解并说明原因:
objectRouteTable{fundeepLink(path:String,animated:Boolean=true):Uri{/* ... */}}// Java: RouteTable.deepLink("/home");

第 3 章 SAM 转换与函数类型:lambda 在 Java 侧的落点

Kotlin 对函数式接口(仅一个抽象方法的 interface)自动做 SAM 转换:lambda 可替代匿名类。Java 8+ 同样支持 lambda,但 Kotlin 与 Java 在函数类型内联 lambda上并不对称。

Kotlin 定义 SAM(推荐fun interface,K2 下语义清晰):

funinterfaceOnItemClick{funonClick(id:String)}funsetListener(listener:OnItemClick){/* ... */}// Kotlin 调用setListener{id->navigate(id)}
// Java 调用 —— 与 Kotlin lambda 同一 SAMsetListener(id->navigate(id));

Java 定义 SAM,Kotlin 调用

publicinterfaceRetryPolicy{booleanshouldRetry(intattempt,IOExceptione);}
valpolicy=RetryPolicy{attempt,e->attempt<3&&e!isSSLException}

函数类型() -> Unit不是 SAM:若 API 参数是 Kotlin 函数类型而非fun interface,Java 侧不能写 lambda,需Function0<Unit>或改成fun interface

RetrofitCallback、OkHttp 拦截器链是工程里最常见的 Java SAM 边界。Kotlin 侧用 lambda 写很爽,但要意识到异常与 null 契约仍按 Java 规则

private val retrofit: Retrofit by lazy { val client = OkHttpClient.Builder() .connectTimeout(6, TimeUnit.SECONDS) .readTimeout(6, TimeUnit.SECONDS) .build() Retrofit.Builder() .baseUrl("https://shuaici.github.io/") .client(client) .addConverterFactory(ScalarsConverterFactory.create()) .build() }

此处 Retrofit/OkHttp 全是 Java API;Kotlin 只是在边界内配置 Builder。若把Callback包一层 Kotlinsuspend,应在 Repository 内收口,而不是让 Java 模块直接依赖Continuation

原理补充

SAM 转换在编译期生成invokedynamic或匿名类;fun interface显式标记 SAM,避免多抽象方法接口误用。内联高阶函数(inline fun)的 lambda不能稳定传递给 Java 存储,混编 API 面应使用非 inline 的fun interface或接口类型。

踩坑

  • (T) -> R当公共 API 暴露给 Java 模块——Java 无 SAM 转换。
  • Kotlin lambda 捕获可变var,在 Java 回调异步触发时读到过期值。
  • 对 JavaRunnable{ }却期望返回值的映射逻辑——Unitvoid混淆。

动手练

  • C:定义fun interface Validator,实现validate(input: String): Boolean,并写 Kotlin / Java 各一行调用骨架。
  • B(起点):将 Java 匿名类改为 Kotlin SAM lambda:
button.setOnClickListener(newView.OnClickListener(){@OverridepublicvoidonClick(Viewv){submit();}});

第 4 章 @Nullable、@NotNull 与跨语言 null 契约

仅靠 Kotlin 的T?无法让 Java 调用方「看见」可空性;需要在 JVM 字节码上携带JSR-305 / Jetpack 可空注解,Kotlin 编译器才能把 Java API 读成TT?,而不是T!

常用注解(Android 工程优先 Jetpack):

注解含义Kotlin 侧读取
@Nullable允许 nullT?
@NotNull禁止 nullT
无注解未知平台类型T!

Kotlin 暴露给 Java

importandroidx.annotation.Nullableimportandroidx.annotation.NonNullclassUserBridge{@NullablefunfindName(id:String):String?=repository.find(id)?.name@NonNullfunrequireName(id:String):String=findName(id)?:throwIllegalArgumentException("missing$id")}

在 Kotlin 文件里为 Java 互操作设默认

@file:JvmName("OrderCompat")@file:JvmDefaultWithoutCompatibilitypackagecom.example.order

@JvmDefaultWithoutCompatibility影响接口默认实现与 Java 二进制兼容,适用于库作者;App 模块更常见的是对对外模块的 public API 补齐@Nullable/@NonNull

与 Kotlin 空安全协同:从带@Nullable的 Java 方法返回值赋给 Kotlinval x: String会编译报错,迫使在边界处理:

funbindTitle(javaApi:LegacyApi){valtitle:String=javaApi.getTitle()// 若 getTitle 标 @Nullable,应改为 String?// ...}

团队规范建议:新 Kotlin public API 一律显式T?/T;供 Java 调用的模块同步加 Jetpack 注解;遗留 Java 无注解 API 在 Adapter 层一次性收窄。

原理补充

Kotlin 编译器内置对 Android、org.jetbrains.annotationsjavax.annotation等可空注解的识别表。混编时开启-Xjsr305=strict(或项目默认的 strict 模式)可把无注解 Java 类型当更宽松平台类型,具体以模块build.gradle.kts为准。

踩坑

  • Kotlin 返回String?却未标@Nullable,Java 调用方静态分析工具仍当非空。
  • 混用javax.annotationandroidx.annotation,部分工具链只认其一。
  • 在 Kotlin 内部用!!「帮 Java 兜底」,把契约推迟到崩溃栈。

动手练

  • A:Java 方法String getNote()实际可能返回 null,Kotlin 侧应要求加哪个注解、读到什么类型?
  • D:搜索工程里androidx.annotation.Nullable或 Java 源文件无注解的 getter,列 1 处并给出 Kotlin 边界收窄写法。

第 5 章 suspend 的 Java 视角:Continuation 与调用边界

suspend函数是 Kotlin 协程的核心,但JVM 字节码上不是「多一个 async 关键字」,而是带有隐藏Continuation参数的状态机。Java不能像 Kotlin 一样直接await loadUser(),这是混编里最容易被误解的一点。

Kotlin 编译后(概念性)

suspendfunloadUser(id:String):User// 近似 JVM 签名:// Object loadUser(String id, Continuation<? super User> continuation)

因此:

  • Java 调用 Kotlinsuspend:应使用kotlinx-coroutines-core提供的适配(如FutureKt.futureBuildersKt等),或在 Kotlin 侧提供非 suspend 包装给 Java。
  • Kotlin 调用 Java:普通阻塞 API 用withContext(Dispatchers.IO);勿假设 Java 回调能自动变suspend,需suspendCancellableCoroutine手动挂起。

给 Java 模块的推荐包装

classUserRepository(privatevalapi:UserApi){suspendfunloadUser(id:String):User=api.fetch(id).let(::mapUser)// Java 调用面:显式阻塞或 Future,不把 Continuation 泄漏出去@JvmOverloadsfunloadUserBlocking(id:String,timeoutMs:Long=5_000):User?=runBlocking{withTimeout(timeoutMs){loadUser(id)}}}

生产环境更推荐Kotlin 协程层完成异步,Java 只接回调或ListenableFuturekotlinx-coroutines-guava),避免在 UI 线程调用runBlocking

从 Java 回调到 suspend(Repository 内收口):

suspendfunfetchLegacy():Data=suspendCancellableCoroutine{cont->legacyJavaClient.load(object:LegacyCallback{overridefunonSuccess(value:Data){if(cont.isActive)cont.resume(value)}overridefunonError(t:Throwable){if(cont.isActive)cont.resumeWithException(t)}})}

ViewModel 层继续viewModelScope.launch { },Java Activity 若仍存在,只观察LiveData/StateFlow的 Java 友好暴露(asLiveData()),而不是直接调suspend

原理补充

K2 下 suspend 状态机与 CPS 变换稳定;@JvmSuppressWildcards/@JvmWildcard影响泛型在 Java 的可调用性,与Continuation泛型协变一起决定库 API 是否「Java 友好」。本篇记住:suspend 是 Kotlin 语法,不是 Java 一等公民

踩坑

  • Java 代码直接调suspend fun,编译器报缺少Continuation参数。
  • runBlocking里调 UI 相关suspend,主线程死锁。
  • suspendCancellableCoroutine泄漏到 public API,Java 侧无法完成。

动手练

  • A:用一句话向 Java 同事解释为什么不能userRepo.loadUser("1");直接拿User
  • C:为已有suspend fun loadProvince(id: String): Provincefun interface回调包装loadProvinceAsync(id, onResult: (Province?) -> Unit)的 Kotlin 骨架(5 行内,内部CoroutineScope启动即可)。

第 6 章 API 边界治理:模块分层与混编反模式

互操作能力齐全后,Senior 工程师关注的是边界画在哪:不是处处@JvmStatic,而是让 Java 与 Kotlin 各写擅长的层,契约在接口上闭合。

推荐分层:

Java 遗留 UI / 老 SDK 适配 ↓ 平台类型 / @Nullable 收窄 Kotlin Domain + Repository(suspend、Flow、data class) ↓ @JvmStatic 工具、@JvmOverloads 构造 对外 SDK / 供 Java 模块调用的 Facade

Facade 模式示例

objectProvinceFacade{@JvmStaticfunloadDisplayName(context:Context,provinceId:String,callback:OnNameLoaded,){valscope=CoroutineScope(SupervisorJob()+Dispatchers.Main.immediate)scope.launch{valname=runCatching{withContext(Dispatchers.IO){repository.load(provinceId).nameCn}}.getOrNull()callback.onLoaded(name)}}funinterfaceOnNameLoaded{funonLoaded(name:String?)}}

Java 老页面只依赖ProvinceFacadeOnNameLoaded,不碰suspendStateFlow

反模式清单:

反模式问题改法
平台类型穿透 UI随机 NPE映射层?:/let收窄
处处runBlocking桥接阻塞与死锁Facade + 主线程回调
public API 用函数类型Java 无法 lambdafun interface
@Nullable的 Java 返回Kotlin 当非空用补注解或 Adapter 校验
!!处理 Java 边界契约失效显式T?+ 降级

动手练

  • B(起点):下面混编代码有三处边界问题,列出并改写:
classBadBridge(privatevalbundle:Bundle){funtitle():String=bundle.getString("title")!!// ①funload(cb:(String)->Unit){/* ... */}// ② Java 调不了suspendfunrefresh(){/* ... */}// ③ Java 直接调}
  • D 验收清单:选 1 个 Kotlinobject工具类,确认 Java 模块能否静态调用;若不能,补@JvmStatic并记录调用前后差异。

面试速查 · 追问链

追问链 #1:什么是平台类型T!? 🔥

标准回答(≤200 字):平台类型是 Kotlin 引用 Java(或无 null 注解)声明时的特殊类型,对编译器而言可空也可非空,写作T!。编译器不强制 null 检查,运行时仍可能 NPE。应在 Repository/ViewModel 边界立刻收窄为TT?,不要穿透到 UI。

追问 1SavedStateHandle.get<String>()在 Kotlin 里算什么?
:来自 Java/Android API 的平台类型边界;应get<String>(...)?.let或赋给String?再处理,避免!!

追问 2:Kotlin 返回String?给 Java 要注意什么?
:同步加@Nullable,否则 Java 静态分析与 Kotlin 契约不一致。


追问链 #2:@JvmStatic、@JvmField、@JvmOverloads 分别解决什么? 🔥

标准回答(≤200 字):@JvmStaticcompanion/object成员在 Java 里是真正静态方法;@JvmField暴露 public 字段,跳过 getter;@JvmOverloads为带默认参数的构造/方法生成 Java 重载。混编库对外工具类几乎必备@JvmStatic

追问 1:Kotlin 默认参数 Java 为什么调不了?
:JVM 只保留全参签名;需@JvmOverloads或 Java 侧手动传齐参数。

追问 2const val需要@JvmField吗?
:不需要,const val已是编译期常量,Java 直接用类名访问。


追问链 #3:Kotlin lambda 什么情况下 Java 不能用? ⭐

标准回答(≤200 字):参数是 Kotlin 函数类型(如(String) -> Unit)时,Java 不能 SAM 转换,需Function1等或改成fun interface。只有 Java 函数式接口或 Kotlinfun interface才支持 lambda 互调。内联高阶函数的 lambda 不宜存给 Java 字段。

追问 1fun interface和普通interface对 Java 有何差别?
fun interface明确单抽象方法,Kotlin/Java 两侧都可 lambda;多方法接口必须匿名类。

追问 2:RetrofitCallback在 Kotlin 里怎么写?
:可用 SAM lambda,但应在 Repository 层封装,避免 Java 模块依赖协程细节。


追问链 #4:Java 能直接调用 suspend 函数吗? ⭐

标准回答(≤200 字):不能。suspend编译后带Continuation参数,是 Kotlin 协程 CPS 形态。Java 应调包装方法:runBlocking(慎用)、Future/ListenableFuture适配,或回调/fun interfaceFacade。Kotlin 侧用suspendCancellableCoroutine把 Java 回调挂起,仅在 Repository 内。

追问 1:为什么主线程runBlocking危险?
:阻塞 UI 线程,且与已挂起的协程争用同一线程可能死锁。

追问 2:老 Java Activity 如何接 KotlinStateFlow
stateFlow.asLiveData()或在 Kotlin Facade 里转回调,不让 Java 直接碰suspend


追问链 #5:@Nullable 和 KotlinT?怎么配合? 💡

标准回答(≤200 字):Java API 标@Nullable/@NotNull后,Kotlin 编译器把返回类型读成T?/T,减少平台类型。Kotlin 对外 public API 也应补 Jetpack 注解,方便 Java 与静态分析。无注解的遗留 Java 返回视作T!,在 Adapter 一次性校验。

追问 1-Xjsr305=strict有什么效果?
:对 JSR-305 注解更严格,影响无注解 Java 类型的可空推断策略,以模块配置为准。

追问 2!!和边界校验哪个更适合混编?
:边界校验 + 显式T?!!只用于逻辑上不可能 null 且可测试覆盖的点。


完整链路一句通

Java 进来的平台类型在边界收窄为T?对外 API 用@JvmStatic/@JvmOverloads/@JvmField塑 Java 调用面SAM 与fun interface承载 lambda@Nullable/@NotNull写清 null 契约suspend仅在 Kotlin 层,Java 走 Facade/回调/FutureRepository 收口,不让混编细节穿透 UI


相关推荐

Kotlin 语法与空安全:Android 开发第一课

Kotlin 作用域函数:let/apply 工程选型