Kotlin 的 internal 修饰符到底咋回事儿?

各位 Android 彭于晏,周五好!

前几天,我和同事吃饭的时候,他突然问我:你知道 Kotlin 那个 internal 是咋回事儿么?

我:当然知道,不过在说这个之前,我们应该都复习一下其他的可见性修饰符,你等我,周五给你出文章!

Kotlin 常见修饰符

可见性修饰符决定了程序中类、对象、接口、函数和属性的可访问范围。

Kotlin 一共提供四种可见性修饰符:publicprivateprotectedinternal。它们各自服务于不同场景,为代码元素的访问边界提供了清晰定义。

默认情况下,如果一个声明没有显式写出修饰符,它会被视为 public

Kotlin 社区对于"默认修饰符是 public"这一设计一直颇有微词,很多人认为应该主动声明你要暴露的接口,而非默认全部暴露。

Public

public 修饰符表示这个声明可以从程序中的任何地方访问。这也是所有顶层声明和成员声明的默认可见性。例如:

kotlin 复制代码
class PublicExample {
    public fun greet() = "Hello, skydoves!"
}

这里的 greet 函数可以被任意其他类或文件访问。

Private

private 修饰符会把访问范围限制在同一个文件内(对于顶层声明),或者限制在其所在类或对象内部(对于成员声明)。它非常适合用来做封装。

kotlin 复制代码
class PrivateExample {
    private val secret = "Hidden from the outside"

    fun revealSecret() = secret
}

在这个例子里,secret 属性只能在 PrivateExample 类内部访问。

Protected

protectedprivate 很像,但它允许子类访问成员。它只能用于类成员,不能用于顶层声明。

kotlin 复制代码
open class Parent {
    protected fun protectedMethod() = "Accessible in subclasses"
}

class Child : Parent() {
    fun useProtected() = protectedMethod()
}

这里的 protectedMethod 可以在 Child 类内部访问,但不能在整个类继承体系之外访问。

Internal

internal 修饰符会把声明的访问范围限制在同一个模块内。这很适合在模块内部共享实现,同时阻止其他模块从外部直接访问。

kotlin 复制代码
internal class InternalExample {
    internal fun internalFunction() = "Available within the module"
}

internalFunction 可以在同一模块中的任意位置访问,但不能被其他模块访问。

在实际的 Android 开发中,一个模块通常对应着你定义的一个 Gradle module。可以这么理解:internal 对于模块内部相当于 public,而对于模块外部相当于 private

总结

Kotlin 的可见性修饰符 publicprivateprotectedinternal,为代码元素的访问控制提供了非常细致的粒度。

声明默认是 public,但你可以根据设计需求使用其他修饰符来进行封装或限制访问。合理使用这些修饰符,有助于提升代码的可读性、安全性以及模块化程度。

进阶: internal 到底怎么实现的

internal 是 Kotlin 在封装层面非常实用的一个特性。各位彭于晏一定好奇,internal 是如何工作的?

因为 Kotlin 最终会编译成 JVM 字节码,而 JVM 层面上并没有 internal 这个级别的可见性,那么它到底是怎么实现的呢?

internal 表示某个类、函数或属性在同一模块内是公开的,但对于其他模块中的代码来说则是不可见、不可访问的。

这种机制特别适合把代码共享给库的内部实现使用,同时又不把它暴露为库的公共 API。

更准确地说,一个模块是一起编译的一组 Kotlin 文件,例如:

  • 一个 IntelliJ IDEA 模块。
  • 一个 Maven 项目。
  • 一个 Gradle source set(例外是 test source set 可以访问 main 中的 internal 声明)。
  • 一组通过一次 <kotlinc> Ant 任务调用完成编译的文件。

我们知道,JVM 本身并没有"模块可见性"这个概念。它只认识 publicprotectedprivate 和包私有(默认)这几种访问级别。

为了实现 internal,Kotlin 编译器采用了一种很巧妙的方式:它会把 internal 声明编译成 public,但同时给它们一个经过改写的名称。

先看一个简单的 Kotlin 模块示例(比如一个库),其中包含一个 internal 函数。

kotlin 复制代码
// 这个函数在当前模块内是公开的,但不应该被
// 使用该库的其他模块访问。
internal fun aUsefulInternalFunction() {
    println("This is an internal utility function.")
}

// 一个会使用 internal 函数的 public 类。
class PublicApi {
    fun doWork() {
        // 这个调用是合法的,因为它发生在同一个模块中。
        aUsefulInternalFunction()
    }
}

当这个模块被编译成 .jar 文件之后,如果我们去查看它的字节码(或者把它反编译成 Java),就会看到一个很有意思的变化。

java 复制代码
import kotlin.Metadata;

// 这个文件会被编译成一个带有 'Kt' 后缀的类。
public final class MyLibraryKt {

    // 1. 该函数会被编译为 'public'。
    // 2. 函数名会发生改写,模块名会被追加到后面。
    public static final void aUsefulInternalFunction$MyLibrary_module() {
        String message = "This is an internal utility function.";
        System.out.println(message);
    }
}

// PublicApi 类的字节码会显示它调用的是改写后的名字。
public final class PublicApi {
    public final void doWork() {
        MyLibraryKt.aUsefulInternalFunction$MyLibrary_module();
    }
}
  1. 它会变成 public :在字节码层面,internal 函数实际上会被声明为 public,这是一种妥协。因为同一模块中的代码即便分布在不同的包(package)里,也必须能够相互访问。在 JVM 支持的访问级别中,只有 public 能确保这种跨包的可见性,尤其是在单个编译产物(如 .jar 文件)内部。

  2. 名称改写(Name Mangling) :这才是"隐藏"机制的核心。Kotlin 编译器会给原始函数名追加一个后缀,通常格式是 $[module_name]。在上面的例子中,假设模块名为 MyLibrary_module,那么函数 aUsefulInternalFunction 最终就会变成 aUsefulInternalFunction$MyLibrary_module

这种 public + 改写后名称 的策略,会通过两种方式来落实 internal 可见性:

  • 对于 Java 使用者 :一个 Java 开发者想要使用这个库时,通常不会轻易调用到这个函数。因为他们在 IDE 的自动补全里看不到 aUsefulInternalFunction()。虽然理论上可以手动调用 MyLibraryKt.aUsefulInternalFunction$MyLibrary_module(),但这个改写后的名称已经非常明确地告诉你:这是一段内部的、不稳定的 API,不应该被依赖。换句话说,它已经被有效地"隐藏"在正常使用路径之外。

  • 对于 Kotlin 使用者(位于另一个模块中) :这里体现的是 Kotlin 编译器本身的语义约束能力。当你在另一个 Kotlin 模块中尝试使用这个库时,编译器会从源码或者 .jar 中的元数据里得知这个声明原本带有 internal 修饰符。如果你写下 aUsefulInternalFunction(),编译器会立刻发现它属于另一个模块的 internal 声明,并直接给出一个编译期错误,提示这个函数不可访问。它甚至不会尝试去把它解析成那个改写后的名字。

这是一套非常漂亮的双重策略:一方面,Kotlin 编译器会对其他 Kotlin 模块严格执行 internal 规则;另一方面,名称改写又为 Java 和其他 JVM 语言树起了一个明显的"请勿使用"标志。

同样的原理也适用于 internal 类。它在字节码层面会被编译成一个 public 类,而它的 internal 方法在必要时也可能通过名称改写来避免冲突。不过 Kotlin 编译器依然会阻止其他模块中的代码访问这个 public 类。

一点想法

关于 internal 修饰符,在日常的 Android/Kotlin 开发中,我有以下几点体会:

  1. SDK 与开源库开发的利器

    在开发提供给第三方使用的 SDK 或基础组件库时,internal 是最完美的封装工具。我们可以自由地在模块内部拆分架构、提取公共基类和工具类,而完全不用担心它们会"污染"对外的 Public API。对外暴露的 API 越少,你的维护成本就越低,向后兼容的压力也就越小。

  2. 多模块架构(Modularization)的最佳实践

    在现代 Android 提倡的多模块/组件化架构中,internal 能强迫我们思考模块的边界。写下新类或函数时,默认给它加上 internal (甚至是 private)是一个非常好的习惯。这能有效防止其他模块产生不必要的隐式依赖,当你想重构模块内部逻辑时,不用再满世界搜索还有谁调用了这个方法。

  3. 对 Java 防御的终极手段(@JvmSynthetic)

    虽然 Kotlin 编译器的 Name Mangling 已经让 Java 调用变得非常丑陋,达到了"防君子不防小人"的目的,但 Java 依然可以强行调用改名后的方法。如果你开发的是一个纯 Kotlin 库,并且绝对不想让 Java 代码调用某个内部方法,可以结合 @JvmSynthetic 注解使用,这会让该声明在 Java 源码层面彻底隐身,连调用的可能都不给。

合理使用 internal,你的架构会变得更加内聚和清爽。

相关推荐
鹏程十八少1 小时前
1.2026金三银四 Android Glide 23连问终极拆解:生命周期、三级缓存、Bitmap复用,大厂面试官到底想听什么?
android·前端·面试
空中海2 小时前
第九章:安卓系统能力与平台集成
android·数码相机
阿拉斯攀登2 小时前
20 个 Android JNI + CMake 生产级示例
android·java·开发语言·人工智能·机器学习·无人售货柜
空中海2 小时前
第十一章:Kotlin 进阶与 Android 原理
android
studyForMokey2 小时前
【Android面试】设计模式专题
android·设计模式·面试
三少爷的鞋2 小时前
别再写 BaseXXX 了:BaseActivity 和 BaseViewModel 正在毁掉你的架构
android
Trustport2 小时前
ArcGIS Maps SDK For Kotlin 加载Layout中的MapView出错
android·开发语言·arcgis·kotlin
好家伙VCC2 小时前
# ARCore+ Kotlin 实战:打造沉浸式增强现实交互应用在
java·python·kotlin·ar·交互
EQ-雪梨蛋花汤2 小时前
【笔记】安卓毛玻璃效果(Blur)实现笔记(使用BlurView)(结尾附:源码)
android·笔记