字符串模板这题的"结论"很简单:"$name"、"${expr}"、"""..."""。但真正值得讲的有三件事——模板在编译后变成了什么(StringBuilder 还是常量折叠)、原生字符串的 trimIndent 为什么必需、以及在 Android 上一个高频性能坑:循环里用模板做字符串拼接。这三点能从"语法"讲到"字节码"再到"性能",层次就出来了。
先把结论放在前面:Kotlin 的字符串模板 "Hello, $name" 编译后走 StringBuilder.append 链(等价于 Java 的 "Hello, " + name);而所有插值部分都是常量时,编译器会做常量折叠,直接生成一个字符串常量。原生字符串 """...""" 支持多行、不转义引号与反斜杠,但会保留源码的缩进,所以几乎总是要配 trimIndent()。$ 本身要用 \$ 转义,${'$'} 可以输出美元符号。
机制拆解
先看模板的编译。"a${b}c$d" 编译成 new StringBuilder().append("a").append(b).append("c").append(d).toString()。如果所有片段都是编译期常量("a" + 1 + "c" 里的 1 是字面量),编译器会折叠成一个常量字符串。这个优化的存在意味着:写 "${list.size} 个" 在 list 为局部变量时不会有额外开销,但写 "$a$b" 在两个都是 var 引用时就会构造 StringBuilder。 差别不大,但解释了为什么常量拼接的日志不会拖慢性能。
原生字符串的实现更值得说。"""...""" 在编译期会把源文件的原始文本(包括换行与缩进)作为常量取出,所以缩进被完整保留。trimIndent() 的作用是:检测所有行的最小公共缩进并移除它,同时去掉首尾空行。trimMargin() 则是另一种常见用法——用 | 作前缀标记,只去掉带 | 的行的前缀,保留代码块的相对缩进(比如嵌在 Markdown 代码块里)。Kotlin 官方文档里的 KDoc 示例就是用 | 的。
$ 的转义规则也常被问:模板里 $ 后面跟标识符或 {
才是插值,所以纯美元符号写成 \$;如果 \$ 在原生字符串里就不生效(原生字符串不处理转义),需要用 ${'$'} 这种写法。中文场景里还有一类实际问题:金额与用户输入的拼接——"$name 余额 $money 元" 在用户名字里含特殊字符时没问题,但如果模板内容被用来做 URL 或 SQL,就会带来注入风险。
这些坑的正确绕法
最常见的坑是原生字符串忘记 trimIndent,输出带着层级缩进直接进了展示层。 表现是日志里多出一堆空格,UI 上多出一段缩进;更糟的情况是把带缩进的字符串拿去做等值比较或缓存 key,导致始终匹配不上。根因是原生字符串忠实保留了源码缩进,而人眼在源码里看不出来。修法是所有多行原生字符串一律加 .trimIndent(),或者在需要精确控制时用 trimMargin("|")。
其次是循环里用字符串模板做累积,产出大量临时对象。 表现是处理大列表时 GC 频繁、卡顿;单看代码很简洁,完全看不出问题。根因是每次迭代都新建 StringBuilder 并 toString() 生成新字符串,复杂度从线性退化成平方级。修法是显式声明一个 StringBuilder 在循环外累积,最后 toString() 一次;或者收集到列表后用 joinToString。这一条是 Android 侧最实际的性能点。
还有一个更隐蔽的坑:模板里嵌 null 打出字面量 "null"。 表现是用户 id 为空时,日志和界面显示成"用户 null",排查时误以为传入了字符串 "null"。Kotlin 里 "$maybeNull" 在 maybeNull 为 null 时输出 "null"(不抛异常)。修法是显式处理:maybeNull?.toString() ?: "未知",或在展示层用 Elvis。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
val name = "amy"
val age = 30
val msg1 = "用户 $name,$age 岁" // 编译成 StringBuilder 链
val msg2 = "用户 ${if (age > 18) "成年" else "未成年"}" // 表达式可嵌
val msg3 = "价格 $1.5" // 输出 1.5,但建议写 ${
'$'}1.5
val dollar = "100\$" // 纯美元符需转义
val dollar2 = "100${'$'}" // 或用表达式写法
// 原生字符串:多行 + 不转义,但保留缩进
val raw = """
line1
line2
"""
val fixed = raw.trimIndent() // 缩进被清理,必须写
val md = """
|# 标题
|正文
|- 项目
""".trimMargin("|") // 按 | 标记去前缀,保留相对缩进
// 1) 循环里用模板 = 平方级开销(反例)
var s1 = ""
repeat(1000) {
i -> s1 += "item$i " } // 每次新建 StringBuilder
// 2) 正解:显式 StringBuilder 提到循环外
val sb = StringBuilder(1024)
for (i in 0 until 1000) sb.append("item").append(i).append(' ')
val s2 = sb.toString()
// 3) 或者收集后一次性 join
val s3 = (0 until 1000).joinToString(" ") {
"item$it" }
// 4) null 会打成字面量 "null"
val maybe: String? = null
val bad = "用户 $maybe" // "用户 null"
val good = "用户 ${maybe ?: "未知"}" // "用户 未知"
// 5) 模板不要用来拼 SQL 或 URL(注入与转义风险)
// 错误:val sql = "SELECT * FROM t WHERE name = '$name'"
val safe = "q=$name".let {
java.net.URLEncoder.encode(it, "UTF-8") } // 编码后再拼
这段代码值得盯三处:第一处,fixed 与 md 两个 trimIndent/trimMargin 用法并列,说明两种清理缩进的方式;第二处,第 1~3 段把"循环里用模板"的反例与两种正解放在一起,这是 Android 侧最容易踩的性能点;第三处,maybe ?: "未知" 是处理 null 拼接的标准写法。
这题在面试里怎么问、怎么答
"字符串模板编译后是什么?"答:变量插值走 StringBuilder.append 链(等价于 Java 的 + 拼接);所有片段都是编译期常量时会被常量折叠成一个字符串常量。所以 "a" + 1 + "b" 这种全常量的拼接不产生 StringBuilder,而 "a" + name + "b" 会。
"trimIndent 的算法是什么?"答:算出所有非空行的最小缩进量,统一移除;再去掉首尾的空行。要注意它对"第一行紧跟 """ 的情况"处理方式与后续行不同,这是缩进不统一时 trimIndent 结果异常的常见原因——解决办法是让 """ 后面直接换行,所有内容从下一行开始写。
"和 Java 的字符串拼接有什么区别?"答:语法上都是拼接,但三点不同:①Kotlin 模板里可以直接写任意表达式,Java 需要字符串连接或 String.format;②Kotlin 的 trimIndent 是语言级能力,Java 要手写 stripIndent(Java 13 才有);③Kotlin 模板对 null 渲染成 "null" 而 Java 的 + 同样如此,两边一致,不是差异点。
"使用中遇到过什么问题?"案例一:多行文案用了原生字符串忘记 trimIndent,导致 UI 顶部多出缩进;修复为统一加 trimIndent。案例二:循环里用模板拼一个大字符串,列表 500 项时卡顿明显;改用循环外 StringBuilder 后卡顿消失。
再补一个工程上值得讲清的一点:Android 上要警惕"隐式字符串模板带来的性能与安全双风险"。 性能侧上面已讲(循环拼接);安全侧是不要用模板拼任何进入解释器的字符串——SQL、Shell 命令、URL 路径、WebView 的 JavaScript bridge 参数,都应该用参数化查询或编码后的值。模板让拼字符串变得太顺手,反而容易埋雷。养成习惯:模板只用于"给人看的文本",需要被解析的内容一律走参数化。这一条能在面试里体现安全意识,比讲语法有用得多。
给正在准备面试的你
把这题画成一张"三种字符串形态"的对照图,三列分别是普通引号 ""、原生 """"""、带 trimIndent/trimMargin 的原生串。每列标注四行特性:是否转义、是否多行、是否保留缩进、Java 侧调用形态(String/StringBuilder)。图下方画一条红色的性能提示,画一个"循环 1000 次用模板"的方框打个叉,旁边写"O(n²)",再画一个"循环外 StringBuilder"的方框打勾。面试时从这张图讲到性能与安全,层次就出来了。
再补工程案例与踩坑——应用落点是给项目里所有多行字符串加 trimIndent/trimMargin,把循环内的模板拼接改成循环外 StringBuilder 或 joinToString,为"用户输入拼进 URL/SQL"的地方补编码或参数化,并对展示层做一次 null 渲染的排查。
复习时别孤立刷题:中缀表达式与运算符重载——repeat(n) { } 就是 Kotlin 自带的中缀用法,"a" times 3 则是自定义的。
划两句重点:模板编译成 StringBuilder 链,全常量时折叠;原生字符串保留缩进,必须 trimIndent/trimMargin;$ 要转义;循环里用模板是平方级开销;模板不用于拼 SQL 与 URL;null 会渲染成字面量 "null"。
下一篇聊区间与遍历:until、downTo 与 step——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:中缀表达式与运算符重载:可读性的双刃剑
下一篇预告:区间与遍历:until、downTo-与-step
有任何问题欢迎在评论区留言交流。