第073篇 字符串模板与原生字符串:多行文本的正确姿势

简介: Kotlin字符串模板核心三点:①编译后转为`StringBuilder.append`链(全常量时优化为字面量);②原生字符串`"""..."""`保留缩进,必须用`trimIndent()`或`trimMargin()`清理;③Android中禁在循环内用模板拼接,否则触发O(n²)性能坑。安全上,模板不用于SQL/URL等需解析的场景。

字符串模板这题的"结论"很简单:"$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

有任何问题欢迎在评论区留言交流。

相关文章
|
3天前
|
云安全 人工智能 安全
本周云安全公告速览与影响解读:哪些漏洞该今晚就打补丁
过去一周漏洞公告里真正需要当天动手的只有几条。本篇用公网可达+无需认证+已有利用三把尺子给公告定级,逐条解读 AI 应用框架代码执行(camel-ai/bisheng/DB-GPT)、MCP 抓取工具 SS元数据、Trivy 与 Langchain-Cha应链风险,并给出 P0 到 P3 的可执行处置清单与避坑要点,帮你从看公告变成能落地。
|
1天前
|
自然语言处理 Java Android开发
第072篇 中缀表达式与运算符重载:可读性的双刃剑
Kotlin运算符重载比Java更彻底:不仅支持`+ - * /`等映射为`plus`/`minus`等约定函数,还允许任意单参函数通过`infix`声明实现中缀调用(如`a to b`)。核心原则是——符号语义必须与原始含义一致(`+`即相加,`-`即取反或相减),滥用将损害可读性。
26 1
|
1天前
|
消息中间件 Java 编译器
第016篇 内部类与匿名内部类:回调写法的底层逻辑
本文深入解析Java内部类与匿名内部类的核心机制:非静态内部类(含匿名类)隐式持有外部实例引用,易致内存泄漏;静态内部类与无捕获lambda则无此问题。涵盖原理、典型坑点(如Handler泄漏)、绕坑方案及面试高分答法,强调从执行路径、数据流向和失败模式展开,助你脱颖而出。
19 0
|
1天前
|
存储 Java 编译器
第002篇 运算符与表达式:整除、短路、位运算与优先级
Android面试高频考点:运算符与表达式。聚焦整除截断、溢出规避、短路求值、位运算(MeasureSpec/Intent flags)、浮点比较、优先级陷阱等真实坑点,结合源码案例讲透原理与避坑实践,助你夯实基础、展现真功夫。
19 0
|
1天前
|
安全 Java API
第027篇 ArrayList 源码与扩容机制:1.5 倍增长的细节
ArrayList面试高频题:JDK8中无参构造不分配数组,首次add才扩容至10;后续按1.5倍增长,不足时直取所需容量,上限为Integer.MAX_VALUE-8。扩容本质是Arrays.copyOf整段拷贝。关键要懂原理、避坑(如预估容量、非线程安全、subList陷阱)并结合工程实践。
19 0
|
1天前
|
缓存 安全 Java
第020篇 == 与 equals 的区别:从栈堆内存说起
面试官问“== 与 equals 区别”,真正考察的是完整心智模型:基本类型==比值,引用类型==比地址;equals默认等价==,重写后比逻辑内容,且**必须同步重写hashCode**。常见坑包括Integer缓存、字符串常量池、HashMap去重失效、枚举误用equals等。工程建议:值对象一律用Objects.equals,包装类/字符串禁用==,枚举用==更安全。
16 0
|
1天前
|
SQL 缓存 安全
第058篇 单例模式六种写法:线程安全与懒加载的平衡
单例模式是面试高频考点,六种写法各具特点:饿汉式线程安全但非懒加载;懒汉式需同步;DCL需`volatile`防重排;静态内部类最推荐(JVM类初始化锁保障懒加载与线程安全);枚举天然防反射与序列化。Android中优先用静态内部类,持Context时务必用`getApplicationContext()`,避免内存泄漏。
25 0
|
23小时前
|
缓存 Java 数据库
第078篇 Sequence 与惰性求值:大数据量集合优化
`Sequence` 的核心是**惰性求值**(操作延迟至终端才执行)与**冷流语义**(每次遍历都重新计算,不缓存)。它省内存(无中间集合),但不支持多次遍历、随机访问;适用于大集合多级过滤或无限序列截断(如 `take`)。慎用于副作用操作、未截断的无限流及需重复消费场景——应物化(`toList()`)或内联使用。
30 0
|
1天前
|
Java 编译器 API
第008篇 面向对象三大特性:封装、继承、多态怎么讲才透
本文深度解析面向对象三大特性(封装、继承、多态)的面试核心:不止于定义,重在讲清“为何设计、有何代价、如何用对”。结合Java机制、代码实操与典型误区,助你构建扎实认知网,从容应对层层追问。
16 0
|
1天前
|
缓存 Java API
第037篇 动态代理:AOP 与框架的基石
动态代理本质是运行期生成代理类,统一转发调用至InvocationHandler。JDK代理限于接口,CGLIB通过子类代理非final类。核心难点在于:代理对象与目标实例不同、方法体动态生成、this自调用绕过代理——这三点正是区分“读文档”与“写框架”的关键试金石。
18 0

热门文章

最新文章