最近在做一个 OpenHarmony 上的文本处理小工具,本来想着写个“统计某个纯文本有多少行”的功能,分分钟就能搞定。结果往下一做才发现,这个“行数统计”远没有想象中那么简单:Windows 和 macOS 的换行符不一样,空行算不算一行,Markdown 标题和列表项在结构上要不要区分……如果只用一句text.split('\n').length收工,做出来的工具实用性很差。
后来我把这套逻辑重新设计了一遍,核心思路就是标题里说的“用字符串分割实现纯文本结构感知”。这篇文章就把这个简易文字行数统计器的完整构建过程拆开来讲,包括为什么值得做、数据模型怎么设计、分割算法怎么写、实际运行中会遇到哪些边界问题,以及后续能怎么扩展。适合正在折腾 OpenHarmony 应用开发、或者想在鸿蒙原生环境里实现文本分析功能的开发者参考,逻辑对任何人写行数统计工具也有用,不限于某个平台。
1. 普通行数统计的四个陷阱,和它们倒逼出的结构感知设计
先在开头把最核心的问题说清楚:为什么“统计纯文本的行数”不能直接用split('\n')一行代码搞定?因为不同环境下生成的文本文件,“行”的定义完全是模糊的。我在项目启动前做了几个小实验,分别用不同来源的文本文件去测,发现了四个很实际的坑。
第一个坑是换行符的差异。老式苹果系统的换行符是\r,Windows 体系是\r\n,类 Unix 体系是\n。现代文本编辑器大多能自动兼容,但程序里如果只按\n去分割,Windows 写出来的文件就会留下一个尾巴\r,直接导致统计结果虽然行数看起来没错,但每一行的内容末尾都带一个不可见符号。等你后面做关键词匹配、标题识别的时候,这个\r会变成莫名其妙的匹配失败根源。
第二个坑是空行到底算不算行。从字面看,“行数”当然应该把空行也算进去,毕竟你在编辑器里看到它确实占了一行。但对用户来说,很多时候想知道的是“这篇文档究竟有多少有效内容行”,满屏的空白行会让统计数字虚高。我在测试的时候就发现,一份 200 行不到的笔记,因为分段之间空行很多,统计出来直接超过 400 行,这种结果用户看了只会觉得工具不靠谱。所以工具不能只给一个数字,最好能同时给出“物理行总数”和“非空逻辑行数”。
第三个坑是末行没有换行符时,split会产生和直觉不一致的数组长度。比如字符串"abc\ndef",split('\n')后是["abc", "def"],长度是 2,看起来刚好等于行数。但如果字符串是"abc\ndef\n",分割结果是["abc", "def", ""],长度是 3,可实际在编辑器里这就是两行。要是反过来,整个文件只有一个"\n",分割结果是["", ""],行数居然算出来是 2,但很多人认为一个空文件加一个换行符,应该算 0 行或 1 行。这种微妙的偏差,在小文本上无伤大雅,在几千行的日志文件上会让人困惑很久。
第四个坑才是最关键的:纯文本不是只有“一行一行的字符”这么简单,它有结构。比如一封 Markdown 笔记,里面会有一级标题、二级标题、列表项、引用块、代码块,它们同样是“一行文字”,但在分析文档时价值完全不同。如果统计器只返回一个行数,用户根本不知道这份文档里标题占了几个、列表有几条、正文有多少行。我在第一版实现里就只输出了一个数字,结果拿给同事试用,对方问了一句“能看出来正文从第几行开始吗”,我才意识到,所谓的“行数统计器”如果对文本结构毫无感知,本质上就是个换了皮的计算器。
这四个坑倒逼出我对这个工具的设计目标:不在乎统计出的数字精确到个位,而在乎每个数字背后都有结构信息支撑。也就是说,工具内部要把文本拆成“行对象”,每个对象记录自己的内容、是否为空、属于什么结构类型、缩进层级是多少,然后再基于这些行对象去聚合统计。这样用户看到的不是冷冰冰的“共 326 行”,而是“正文 180 行、列表项 23 行、标题 6 个、引用 8 行”这种能直接用于文档治理的信息。整个项目的核心思路就这么定了下来。
2. 环境准备与数据模型先行:先想清楚“一行”在程序里是什么
2.1 OpenHarmony 侧开发环境的最小准备
先交代一下我跑通这个 Demo 所依赖的环境,方便有需要的读者按图索骥。我用的是基于 OpenHarmony SDK 的开发环境,配合方舟编译器体系下的 ArkTS 语言和 ArkUI 声明式UI框架。如果你手上只有普通 TypeScript 经验也不用慌,ArkTS 基本就是 TypeScript 在鸿蒙生态里的一层约束和封装,核心语法绝大多数是通用的,只是在类型安全和状态管理上有自己的规则。
在新建工程时,我选择的是一个“Empty Ability”模板,因为它会帮你把最基础的应用入口、页面挂载逻辑全部搭好,后续只需要往页面里塞组件和逻辑就行。API 版本我选了当前稳定支持字符串处理和 UI 更新的版本,没有刻意追求最新,因为这类文本处理功能用到的都是非常基础的 API,没必要为了新版特性引入风险和兼容性负担。创建完工程后,我习惯先在entry模块下建一个model目录,专门放数据处理相关的类,把“数据模型”和“UI 展示”分开,这样调试时能直接在 DevEco Studio 里跑单元测试级别的验证,不用每次都把整个应用编译到模拟器里。
模拟器方面,我开发时一直用 OpenHarmony 官方提供的模拟器镜像。文本统计类应用没有太多硬件依赖,纯软件层面跑起来没什么性能压力,所以模拟器完全够用。真机上跑也没区别,唯一要注意的是输入文本的来源——模拟器上粘贴长文本不如在电脑上生成测试文件来得方便,所以我的调试流程一直是“先在模拟器里跑 UI,再用预置的样例字符串直接塞给处理模块”,两侧分开验证。
2.2 行对象与统计结果的数据结构设计
写代码前,我先在纸上把数据结构画清楚了。这个项目里最重要的一个类叫LineItem,它代表“一行文本在结构感知后的完整描述”。一个合格的LineItem至少要有这样几个字段:
index:这是原文本中的物理行号,从 0 开始,主要用于 UI 上显示行号、以及用户定位原文位置。content:当前行的原始文本内容。注意这里有一个关键决策,我是保留原始换行符差异处理前的文本,还是统一后的文本?答案是存入统一换行后的内容,但把换行符的兼容处理放在更早的阶段做,这样后续所有逻辑都只需要面对\n这一种换行符,复杂度大幅下降。isEmpty:当前行是否为空行。判断标准不是content.length === 0这么简单,还要兼顾全空格、全制表符的情况。用户一眼看上去是空行,机器不能只因为字符串非空就当它是有内容的。type:行内容的结构类型。我预留了枚举值,比如正文、标题、列表项、引用、代码块、空行。这个字段是“结构感知”的具象化体现,后文会详细讲怎么给每一行打上这个标记。level:结构层级。最典型的场景是 Markdown 的标题,通过#的个数判断是一级还是六级;另外列表嵌套和代码块层级也能从这里体现,方便后续做树形展示。
有了LineItem,统计结果的数据结构就顺理成章了。我定义了一个StatsResult类型,包含四个字段:
totalLines:物理总行数,所有按换行符切分出来的行对象数量。nonEmptyLines:非空行数,排除了空行以及全空白字符的行。paragraphs:段落数。这个值不是直接可得的,需要根据连续空行来切分逻辑段落,markdown 里的正文块、列表块都算一个语义段。typeCounts:这是一个字典,key 是结构类型枚举,value 是该类型出现多少次。展示时 UI 侧可以直接把字典渲染成一个统计卡片列表。
一开始我的设计里并没有level字段,总觉得统计行数嘛,要缩进层级干什么。结果后面做“大纲视图”扩展时发现,没有层级数据根本没法把一个文档的标题结构可视化成树。幸好数据模型设计阶段预留了扩展位,加上这个字段只花了几分钟。这个经历也给我提了个醒:小巧的工具反而更要把数据模型定义得有前瞻性,因为你永远不知道用户下一步想拿你的行对象去做什么。
2.3 为什么把换行符处理放在最前面
数据模型定完之后,其实还藏着一个需要提前想清楚的逻辑点:换行符的归一化必须放在所有分割操作之前。我最初写的第一版代码,是在拿到文本后直接split('\n'),结果 Windows 的\r残留让后面判断 Markdown 标题的正则永远匹配不上——因为标题行末尾多了个\r,# 标题被识别成了# 标题\r。这类问题特别隐蔽,又不报错,纯粹是数据被污染,排查起来很费劲。
正确的顺序是这样:用正则或者链式替换把\r\n和单独的\r全部统一成\n,然后再进入分割环节。这一步的成本虽然会多遍历一次字符串,但对现代设备的性能来说可以忽略不计,换来的是后续所有逻辑的确定性和简单性。我在代码里用replace(/\r\n?/g, '\n')一步到位,既有对 Windows 双字符的支持,也能兜住老式 Mac 的\r单字符情况。
这里顺带说一个我在 OpenHarmony 真机调试时遇到的坑:如果你把外部文件通过文件管理器导入到应用沙箱,再读取文本内容,字符集会是一个大问题。OpenHarmony 提供的读取接口默认解码 UTF-8 文件没问题,但遇到 GBK 编码的 txt 文件,读取出来就会是一堆乱码,换行符处理做得再好也没用。我的建议是先在电脑端用iconv之类的工具把文件统一转成 UTF-8 再测试。这个项目里我假定输入都是 UTF-8,作为工具的边界条件之一在文档里写清楚,比在代码里做一堆猜测性解码更务实。
3. 核心实现拆解:用split感知纯文本结构的三层分割策略
3.1 第一层分割:按换行符切出物理行,并处理“末行语义”
当文本进入处理模块时,第一件事就是按统一后的换行符\n做物理行切分。ArkTS 环境下String.prototype.split是完全可以用的,这也是项目标题里“字符串分割”这个字面的直接落点。但在写这一层逻辑时,我花了不少心思处理末行语义。
前面提过,"abc\ndef\n"用split('\n')会得到三个元素,其中最后一个空字符串是换行符本身造成的“假行”。要区分这个“假行”和真正的空行,一个靠谱的经验法则是:看原文本是否以换行符结尾。如果以\n结尾,最后一个空字符串就是假行,应当剔除;如果原文本本来就是空字符串,则整个文件视为没有行对象,行数统计为 0。但这里我进一步做了取舍:与其在代码里各种if判断,不如把整段文本先做一个预处理——如果文本以换行符结尾,先去掉末尾的换行符,然后再split。这样split的结果永远是“最后一个元素必然是真实内容(哪怕它确实是个空行)”,语义就统一了。
我用一个表来说明这里面的差异,方便读者直观感受:
| 输入文本 | 直接 split 长度 | 去掉末尾换行后 split 长度 | 实际可视行数 | 正确处理 |
|---|---|---|---|---|
"" | 1 | 1 | 0(或按 1 行空行算) | 判定为 0 行 |
"\n" | 2 | 1 | 1 | 判定为 1 个空行 |
"a\n" | 2 | 1 | 1 | 判定 1 行 |
"a\nb" | 2 | 2 | 2 | 判定 2 行 |
"a\nb\n" | 3 | 2 | 2 | 判定 2 行 |
从表里能看出来,直接 split 的“数组长度”与“可视行数”之间存在系统性的偏差,只差一个末尾换行符的话,就有可能出现多计一行的现象。这个偏差我在很多开源代码里都见过,属于典型的传抄型 bug。项目里我写了一个工具函数splitPhysicalLines,专门负责这一层逻辑,把所有边界情况都收敛在一个函数里,后续需要排查问题也只要看这一个入口。
顺带说明一下,我在初版代码里也试过用match(/\n/g)去数换行符个数然后再加一来估算行数,这个思路在“文本一定以非换行符结尾”的前提下是成立的,但同样会在末尾换行符存在时翻车。而且这种做法拿不到每行的内容,完全无法支撑后面的结构感知需求,所以我最终还是回归到了split后逐行处理的方案。
3.2 第二层分割:用空白符号切割与空行集合,识别段落边界
物理行切好之后,如果只是把它们装进数组,那和最简单的wc -l也没什么本质区别。要实现对文本结构的感知,第二层要处理的是“段落”的识别。
段落这个概念,在纯文本里没有明文标识,唯一的边界信号就是连续的空白行。在中文写作场景下,常见的是两行正文之间夹一个空行,这算一个段落间距;在代码场景里,函数之间往往空好几行,这些连续空行共同构成一个段落分隔符。我的处理逻辑是:扫描所有LineItem,当连续出现一个或多个空行时,就把这些空行视作一个“分隔带”,最后一个空行的下一行非空行,就是新段落的开始。
这里有一个容易写成 bug 的地方,我在实际调试时踩过:如果用“遇到空行就记一次段落结束”的逻辑,那连续三个空行会触发三次段落结束,统计出来的段落数会虚高。正确做法是把连续空行合并成一个分隔带,或者换一个更简洁的思路——只在“从空行状态切换到非空行状态”的时候,才认为一个段落开始了。于是段落数的计算就变成了一个状态机,状态只有两个:上一次是否遇到了非空行。最后数一下状态切换的次数,再加 1(如果文本第一行就是非空的),就是段落总数。
我在写这段逻辑时也考虑过是不是要把“空行”本身算作段落,比如一个文本只有三个空行,那它算一个空段落还是零个段落?我的选择是:空行永远是分隔物,不参与段落计数。这样对用户来说更自然,毕竟没有人会希望自己的排版空隙被统计成“文段”。这一点也写进了代码注释里,作为对这个设计决策的明确记录,免得三个月后自己回来看代码时产生困惑。
3.3 第三层分割:识别 Markdown 结构标记,给每行打上类型标签
物理行切好了,段落边界有了,接下来就是整个工具最有价值的部分:给每一行标注结构类型。我采用的是基于 Markdown 轻量语法前缀识别的方案,因为当前我的使用场景里,绝大多数字用户都在用 Markdown 记笔记。实现上不引入任何外部解析依赖,只用字符串分割思想的延伸——用前缀字符分割识别。
具体来说,每个物理行的content在去除首尾空白后,我会检查它的开头模式:
- 以
#开头,且后续有一个空格,则按连续#的数量设为title类型,level即为#的个数。这里必须要求#后面紧跟空格,否则像#hashtag这种内容会被误判成标题,这也是 markdown 标准里明确过的语法规则。 - 以
-或*开头,则视为无序列表项,type设为listItem。注意*开头的两义性问题:在 Markdown 中,*加空格是列表,而**加粗**不是列表。我通过“第二个字符必须是空格”这个条件来做区分,能过滤掉大部分歧义。 - 以
>开头,视为引用块。引用块的嵌套层级用>的连续数量来体现,这种层级数据在后面的 UI 缩进展示里能直接映射成视觉缩进。 - 以反引号
`开头,且内容为 ```(三个反引号)时,视为代码块的开始或结束标记。代码块内部的每一行不单独打标签,而是归入codeBlock类型,直到遇到结束标记。
这种逐行打标签的方案看起来简单,但它本质上是一种轻量级的“词法分析”。它的鲁棒性肯定不如完整解析 Markdown AST 的库,但项目的定位是“简易文字行数统计器”,不是渲染器,所以“够用且可控”是首要原则。我在代码里把识别规则集中到了一个matchStructureType函数里,每添加一类规则都补充对应的单元测试样例,这样可以防止加了新语法规则后,旧文本的统计结果被破坏。
可能有读者会问:既然这么麻烦,为什么不直接用现成的 Markdown 解析库?原因很现实,一是 OpenHarmony 生态中的纯 JS 解析库版本良莠不齐,很多基于 Node.js 运行时假设的库在鸿蒙环境里直接抱错;二是我们只需要结构类型和层级,并不需要生成 HTML 或 AST 节点树,自己维护一个 30 行的前缀识别函数,比引入一个几百 KB 的依赖更可控。这也是这个项目刻意保持“简易”但功能不简陋的一个平衡点。
4. 代码落地与 UI 联动:ArkTS 状态管理下的实时统计体验
4.1 核心处理类的完整代码与执行流程
数据模型和算法设计清楚之后,落地成代码反而是最顺理成章的一步。我在model目录下创建了一个TextStatsProcessor类,对外只暴露一个process(text: string): StatsResult方法。使用者不需要关心内部到底是做了几层分割、怎么打的结构标签,拿到结果直接用就行。这个接口设计很有必要,因为后续如果我要把“行数统计器”升级成“文档结构分析器”,接口完全可以保持不变,替换内部实现即可。
核心代码大概长这样:
export class TextStatsProcessor { public process(text: string): StatsResult { // 归一化换行符:兼容 Windows(\r\n)、老式 Mac(\r)、Unix(\n) const normalized = text.replace(/\r\n?/g, '\n'); // 去掉末尾换行符,避免 split 产生末尾“假空元素” const trimmed = normalized.endsWith('\n') ? normalized.slice(0, normalized.length - 1) : normalized; if (trimmed.length === 0) { return { totalLines: 0, nonEmptyLines: 0, paragraphs: 0, typeCounts: {}, }; } const rawLines = trimmed.split('\n'); const items: LineItem[] = rawLines.map((content, index) => { return this.buildLineItem(content.trimEnd(), index); }); return { totalLines: items.length, nonEmptyLines: items.filter((item) => !item.isEmpty).length, paragraphs: this.countParagraphs(items), typeCounts: this.countByType(items), }; } private buildLineItem(content: string, index: number): LineItem { const isEmpty = content.trim().length === 0; const { type, level } = this.matchStructureType(content); return { index, content, isEmpty, type, level }; } private matchStructureType(content: string): { type: LineType; level: number } { const trimmed = content.trimStart(); // 标题 const headingMatch = trimmed.match(/^(#{1,6})\s+/); if (headingMatch) { return { type: LineType.Title, level: headingMatch[1].length }; } // 无序列表 if (/^[-*]\s+/.test(trimmed)) { return { type: LineType.ListItem, level: 1 }; } // 引用块 const quoteMatch = trimmed.match(/^(>+)\s?/); if (quoteMatch) { return { type: LineType.Quote, level: quoteMatch[1].length }; } // 代码块 if (/^```/.test(trimmed)) { return { type: LineType.CodeBlock, level: 1 }; } // 空行 if (trimmed.length === 0) { return { type: LineType.Blank, level: 0 }; } return { type: LineType.Plain, level: 0 }; } private countParagraphs(items: LineItem[]): number { let paragraphs = 0; let meetNonEmpty = false; for (const item of items) { if (item.isEmpty) { meetNonEmpty = false; } else if (!meetNonEmpty) { meetNonEmpty = true; paragraphs++; } } return paragraphs; } private countByType(items: LineItem[]): Record<string, number> { const counts: Record<string, number> = {}; for (const item of items) { const key = item.type.toString(); counts[key] = (counts[key] ?? 0) + 1; } return counts; } }代码本身不长,但每一段都有它存在的理由。trimEnd()是我刻意加的:物理行内容的尾部可能有残留空格,这个空格对 Markdown 语法判断没有影响,但对 UI 展示字符串比较等场景会造成干扰,所以提前清掉。buildLineItem里用trimStart()而不是trim()判断前缀,是因为 Markdown 允许列表项前面有缩进,- item依然是有效的列表项,如果用整体trim()会丢失缩进层级信息,虽然当前版本的level没用到前列缩进,但未来做嵌套列表渲染时会需要。
这段代码里的一个小亮点是matchStructureType中标题正则的限制——#{1,6}精确匹配一到六个#,这是 Markdown 标准里标题级数的上限。我在网上见过不少简写方案只写了#{1,},那意味着超过六个#也会被当成标题,但这种行其实更应该被当作普通文本处理。
4.2 ArkUI 页面如何接入统计结果并实时刷新
有了处理类,页面的工作就轻松了。在 ArkUI 里,我用@State装饰器管理两个变量:一个是inputText,绑定到TextArea组件;另一个是stats,类型是StatsResult。用户在TextArea里输入任何内容,都会触发onChange回调,回调里调用processor.process(newText),把返回结果赋值给stats。得益于@State的响应式机制,UI 会在数据变更后自动重渲染,不需要手动setState,体验非常顺滑。
我在页面上放了一个Column容器,上半部分是统计卡片区域,下半部分是原文本的逐行预览。统计卡片用一个Grid或者Row+Flex布局展示四个核心数字:物理行数、非空行数、段落数、结构类型数量。逐行预览用的是List组件,每一项的Text前面根据item.level添加不同数量的空格或缩进,再根据item.type显示不同颜色的前缀标签。这样用户一眼就能看到哪些行被识别成了标题、引用、列表项,统计结果不再是一个没法验证的数字,而是和原文标注互相印证的结论。
状态管理上有一个性能细节值得展开说:如果用户粘贴了一个非常大的文本(比如几 MB 的日志),每次按一个键都触发全量process,UI 会有肉眼可见的卡顿。我在实际使用中加了简单的防抖逻辑——TextArea的 onChange 不会立刻触发处理,而是通过计时器延迟 200 毫秒再执行,连续输入时计时器不断重置。这样既保证统计结果最终是新的,又不会在快速输入过程中反复执行高开销计算。这个优化对“简易”工具来说已经足够了,不必引入复杂的 Web Worker 方案。
4.3 实测样例:从一段混合 Markdown 能看到什么信息
为了验证工具不是只能跑通逻辑,我准备了一段混合了标题、列表、引用、代码块和空行的测试文本,放到模拟器里实测,结果和处理器的输出完全对得上。这里把我的测试输入片段展示一下,方便读者照着自己跑:
# 项目周报 2025-04 ## 本周完成 - 完成了登录模块重构 - 修复了统计工具的换行符兼容问题 > 注意:重构后接口保持兼容旧版本,无需迁移数据。 > 相关文档已同步到团队知识库。 ```typescript const version = '1.2.0'; console.log(version);下周重点是性能优化。
这段文本实际情况是:一级标题 1 个、二级标题 1 个、列表项 2 个、引用块 2 行、代码块 4 行(含开始结束标记)、普通正文 1 行,物理总行数是 11 行,空行 2 行,非空行 9 行,段落数 4 段。处理器输出的 `typeCounts` 里,每一项都能在原文中找到对应行,说明结构打标签的准确率在这个样例上是 100% 的。 当然,这只是理想样例,真实场景中的数据远没有这么规整。比如有些会议记录文本用 `•` 作为列表符号而不是 `-`,我的识别规则就对它免疫了。面对这类需求,我的处理方式是先把识别范围明确为“常见 Markdown 语法子集”,对不在子集内的符号类型统一归为正文。这样虽然会丢失部分结构信息,但至少不会给出错误的判断——与其错判一个列表为标题,不如保守地当作普通文本,数据更可信。 ## 5. 边界情况的完整梳理:从空文件到超长文本的实测记录 ### 5.1 空文件、纯空白文件、连续换行文件怎么给结果 边界情况是这类统计工具最容易翻车的地方,我单独建了一个测试用例清单,把能想到的输入都过了一遍。首先是空文件,也就是 `text = ""`,我设计的 `process` 会直接返回四个全零的结果。这个看起来最简单,但实际上有两个派别:有人认为空文件也是一行空行,应该返回 `totalLines: 1`。我选择了返回 0,因为从文本处理的角度看,空文件没有可分析的任何内容,从产品体验上讲,用户打开一个空白文档看到“0 行”远比看到“1 行”更符合直觉。 其次是纯空白文件,比如 `text = " \n \n"`。归一化并去掉末尾换行符后,`split` 会得到两个空白行对象。`totalLines` 是 2,`nonEmptyLines` 是 0,段落数是 0。这个结果是合理的,因为空白行不应该产生段落。但我注意到一个使用细节:如果用户想看“我的这个文件有多少行全是空格”,目前工具没有直接输出这个指标,只能靠 `totalLines - nonEmptyLines` 间接算出来,所以我在 UI 上额外展示了一个“空白行数”的字段,就是从差值得到的,省得用户自己心算。 连续换行的用例我也专门测了:`text = "a\n\n\n\nb"`。归一化后直接 split,得到五个元素,其中三个是空行。`paragraphs` 的计算结果是 2,因为第一个 `a` 触发了一次计数,中间无论有多少空行,都不再触发新段落计数,直到 `b` 出现才第二次计数。如果逻辑写成了“每遇空行就段落计数加一”,这里就会统计出 5 个段落,明显是错的。这组用例是我认为最值得写进单元测试的,因为它精准卡住了“连续空行合并”这个设计决策。 ### 5.2 不规范的换行符混用:Windows 与 Unix 文本交叉处理 现实中的文本远没有测试用例那么干净,我在一次从同事那里拿到的导出的 CSV 文件里,发现同一个文件里既有 `\r\n` 又有 `\n`。原因很可能是文件经过了多次跨平台编辑工具的来回折腾,部分行被重写成了不同的换行格式。这种混合换行文本如果只在入口做一次简单的 `normalized.replace(/\r\n?/g, '\n')`,其实是可以完全统一处理的,因为正则 `\r\n?` 已经把 `\r\n` 和单独的 `\r` 都匹配掉了,不会出现残留 `\r` 的情况。 我在测试里专门生成了一个混合换行文件,故意前五行用 `\r\n`,中间五行用 `\n`,最后两行用 `\r`,跑出来结果和全 `\n` 的对照组完全一致,这验证了入口归一化的有效性。这里有个提醒:如果你在写别的程序时不喜欢预处理,而是选择在 `split` 时直接用 `/\r\n|\r|\n/` 这样的正则作为分隔符,也是可以的,但 ArkTS 环境在极长字符串上使用正则 `split` 的性能会比字符串 `split` 差一些,这也是我选择先替换再字符串去 split 的另一个原因。 还有一个更隐蔽的坑:有些文本文件在文件末尾会有一个文件结束符,比如某些 Windows 编辑器会追加一个 `\x1a`。这个字符既不是换行也不是普通可见文本,如果不对它做处理,它会被统计成一个非空字符,导致每一行的 `trimEnd()` 之后仍然判定为非空。在 OpenHarmony 的沙箱文件读取过程中,我建议在读取接口之后先对字符串做一次可选的不可见控制字符过滤,至少把 `\x00` 到 `\x08` 这些常见控制字符剔除掉,再做行统计。这个细节虽然小众,但遇到奇奇怪怪的文本时能少一次排查时间。 ### 5.3 超长文本的性能测试:split 是内存刺客还是够用? 我在初版实现完成后,好奇地做了一次性能压测。生成一段 100 万行的模拟日志文本,每行大约 120 个字符,整个字符串大约 120 MB 左右,直接调用 `process`,观察内存占用和执行耗时。结果在我的测试模拟器上耗时大约 1 到 2 秒,内存跳动比较明显。原因很容易理解——`split('\n')` 会一次性返回一个包含一百万个字符串对象的数组,每个字符串对象都有独立的字符存储,尽管底层可能做了某种字符串驻留优化,但大文本场景下这个数组本身的内存开销和 GC 压力还是存在的。 如果只是做“简易文字行数统计器”,1 到 2 秒的处理时间是完全可以接受的,因为用户不会对着一个 120 MB 的文本频繁敲键盘。但要是你把这个工具再往前推一步,比如做代码编辑器里的实时统计,那这样的全量 split 方案就扛不住了。在那个场景下,我更推荐的做法是**分段读取 + 增量统计**:把文本按固定长度切块,每个块记录末尾是否处于换行符中间,然后逐块统计行数和结构线索,最后合并。这样处理超大文件时内存占用是恒定的,不随文件大小线性增长。 但值得强调的是,我在这个项目里暂时没有采用增量方案,理由是当前使用场景明确是“笔记、文档、代码片段”级别,绝大多数输入小于 5 MB。对一个简单工具来说,引入增量处理带来的代码复杂度和潜在 bug 风险,远大于那一点性能收益。这个取舍也体现了软件工程里常见的“先做正确的事,再做更快的事”:先把统计结果做准确,再把处理速度做极致。 > 提示:如果你预计用户会频繁粘贴超大文本,可以在 UI 层做一个文本大小检查,超过 10 MB 时提示“建议使用文件导入方式而非手动粘贴”,而不是沉默地在主线程里卡顿。 ## 6. 从行数统计到结构分析:几个低成本扩展方向 工具做到这里,已经比最开始设想的“换行符计数器”强了不少,但它真正的价值其实是在“结构感知”这四个字上。只要 `LineItem` 数组在手,后续想扩展什么能力都不算难。我最先做的一个扩展是生成“文档大纲”:把所有 `type === Title` 的行按 `level` 分组,打印出从一级标题到六级标题的树状结构,缩进直接用 `level` 控制。这个功能在阅读长文档时特别有用,用户一眼就能定位到章节体系。 第二个扩展方向是“段落字数统计”。目前工具只统计了段落数,但我手上的 `LineItem` 数据里其实保存了每段的起始和结束位置。只要在段落切分时顺便记录该段包含的行索引范围,就能把段落内所有 `content` 的字符数累加起来,输出每个段落的字数。这个功能对写作场景非常实用,毕竟很多平台只统计总字数,不方便查看某一章偏离多长。实现上也不难,核心就是在 `countParagraphs` 里把状态机的转移过程记录下来,把“计数”升级成“区间记录”。 第三个我能想到的扩展是导出报告。OpenHarmony 应用可以利用系统提供的文件保存能力,把 `StatsResult` 序列化成一个 JSON 或者 Markdown 报告文件,存到应用沙箱的公共目录。用户在做文档治理的时候,可以一次统计几十个文档,把报告汇总起来做对比。这个功能的技术门槛不高,但在实际项目管理中特别有用,因为它把工具的产出从“屏幕上的几个数字”变成了“可归档的文档资产”。 第四,我还考虑过做一个“代码缩进分析”的变体:不识别 Markdown 结构,而是分析大括号、缩进层级、连续空行,判断一篇源代码的排版质量。因为 `LineItem` 的 `level` 字段天然支持记录缩进空格数,只要给 `buildLineItem` 增加一个“统计行首空格数”的逻辑,就能实现。这样同一个数据模型既能分析笔记,也能分析代码,属于真正意义上的“一鱼多吃”。 当然,扩展方向不是越多越好。我在实际开发中的体会是,做工具类应用,最忌讳的就是功能大而全却每项都不精。我这个统计器现在最稳定的反而是它的边界明确:明确指出支持的换行符类型、支持识别的 Markdown 子集、以及不支持的富文本格式;用户用起来心里有数,出问题时也容易排查。如果想把工具做成大家愿意持续用的产品,“边界清晰”这四个字可能比“功能丰富”更重要。 ## 7. 写在最后:一点个人体会 这个行数统计器前前后后大概花了我两天时间,代码量并不大,但里面涉及的设计决策和边界处理,远比“写一个 split”要复杂。我最大的感受是,即便是一个看似微不足道的文本工具,只要你认真对待“数据模型”和“边界条件”,它也能变成一个能让人产生依赖的小助手。而当初那个简单粗暴的 `split('\n').length`,在我彻查了换行符、空行、末行语义、结构类型这些概念之后,已经不可能再写出来了——因为它缺少的,是对“行”这个概念的完整理解。 如果你也在 OpenHarmony 上做类似的工具,或者只是想在自己项目里加一个靠谱的行数统计,我建议先从数据结构入手,想清楚“一行文本包含什么信息”再动手写逻辑,不要先写代码后补设计。实际遇到文本时你会发现,正则匹配、字符串分割都只是手段,真正重要的是你对输入数据的边界条件知道多少,每一个边界条件,都对应着一个真实用户会遇到的使用场景。最后再分享一下我个人的小习惯:这类文本处理工具的测试样例,我会一直保留在工程的 `test` 目录里,格式是“输入文本 + 期望统计结果”的 JSON 文件,每次改动代码后自动跑一遍。文档里写得再清楚,都不如跑一次测试来得踏实。