十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android文本编辑器开发实战:从文件读写到编码识别全解析

Android文本编辑器开发实战:从文件读写到编码识别全解析 做文本编辑器这个项目纯粹是被我自己逼出来的。当时带团队做内部工具链需要一款能处理日志、支持语法高亮的轻量编辑器市面上要么太重VS Code 全家桶要么没法深度定制。再加上那阵子刚接手一个 Android 老项目的维护Java 手感还没生疏就萌生了自己在 Android Studio 里写一个文本编辑器的念头。做完之后回头看这个项目虽然不算大但把 Android 开发的绝大多数学问都串起来了非常适合用来查漏补缺。这篇文章就把我的设计思路、实现细节和踩过的坑完整梳理一遍希望能给正在用 Java 做 Android 开发、或者想拿个实战项目练手的朋友一些参考。1. 为什么拿“文本编辑器”当作 Android 练手项目最合适说实话很多人一提到 Android 练手项目第一反应都是记账本、TodoList 或者新闻阅读器。这些项目做完确实有成就感但技术含金量往往被低估了。文本编辑器这个选题看似简单实际上它覆盖了 Android 开发的几大核心知识面每一个都能单独拎出来深挖。1.1 覆盖文件存储、权限、UI 状态管理三大痛点文本编辑器绕不开“打开文件”和“保存文件”这一下就把 Android 的存储体系逼出来了。你要处理内部存储和外部存储的差异要适配 Android 6.0 之后的运行时权限还要面对 Android 10 之后的分区存储限制。这些问题在实际企业开发中天天遇到但大多数新手项目根本碰不到。UI 状态管理也是文本编辑器的一大考验用户正在编辑的文本内容、光标位置、是否修改过、撤销栈的状态这些都散落在界面的各个角落。如果不在onSaveInstanceState和ViewModel里做好规划一旦屏幕旋转或者切到后台再回来用户打了一半的字可能全没了。这个场景很真实也很能检验你的架构基本功。1.2 从 MVP 到完整产品的天然演进路径文本编辑器的另一个优势是它的演进路径非常清晰。最开始你可以只做一个EditText加上打开保存按钮这就能跑通基本流程。然后慢慢加多标签、最近文件、编码识别、语法高亮、撤销重做、搜索替换、行号显示……每加一个功能都对应一个独立的 Android 知识点。这种渐进式开发特别适合按自己的节奏去学不用一开始就背一堆概念。对我来说这个项目还有一个额外的价值Java 版本的实现可以完整保留下来当作团队内部其他项目的参考代码库。很多第三方编辑器用的 Kotlin 和协程但存量 Java 项目的维护依然需要大量原生 Java 的读写范例尤其是文本编码处理这部分。2. 从零搭建项目工程结构与依赖配置的取舍拿 Android Studio 新建项目的时候我特意选了Empty Views Activity而不是 Compose 模板。原因很简单文本编辑器涉及到文件树、滚动画布、行号绘制这些复杂交互传统 View 体系下我在性能和可控性上更有把握。Java 语言配合 View 体系也维护起来最直观。2.1 Gradle 脚本里的关键配置我用的是 Android Studio Hedgehog2023.1.1 Patch 2配 AGP 8.2.2Gradle 8.2。这里有个细节值得说AGP 8.x 版本默认有严格的 buildConfig 开关如果你在代码里用了BuildConfig.DEBUG必须在build.gradle里显式开启android { namespace com.example.texteditor compileSdk 34 defaultConfig { applicationId com.example.texteditor minSdk 23 targetSdk 34 versionCode 1 versionName 1.0 } buildTypes { release { minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro } } buildFeatures { buildConfig true } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } }为什么minSdk定在 23因为 Android 6.0 是运行时权限的分水岭如果你的应用要支持更老的版本权限适配代码会复杂不少。现在市场上大部分设备都在 8.0 以上从 23 起步既省心又不会丢失太多用户。Java 17 是 AGP 8.x 推荐的编译级别如果你的电脑装的是 JDK 11建议尽快升级到 17否则 AGP 8 会直接报警告。2.2 依赖清单能少则少该加则加文本编辑器这个场景真的不需要引入一大堆第三方库。我最终跑通的依赖非常克制dependencies { implementation androidx.core:core:1.12.0 implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.11.0 implementation androidx.constraintlayout:constraintlayout:2.1.4 implementation androidx.recyclerview:recyclerview:1.3.2 // ViewModel 和 LiveData用于跨配置变更保留编辑状态 implementation androidx.lifecycle:lifecycle-viewmodel:2.7.0 implementation androidx.lifecycle:lifecycle-livedata:2.7.0 // 用于撤销重做操作的堆栈管理小而精 implementation com.github.worker8:undo-redo:1.0.0 }有些人的第一反应是会想加个 RxJava 或者协程但文本编辑的核心操作读写文件、保存用Thread和Handler完全够用。加了响应式框架反而让团队里基础不好的同事看不懂。另外撤销重做那个库我后来其实也撤掉了因为它对跨EditText长文本的支持并不好自己写一个栈管理反而更稳定。这个细节后面章节详细说。3. 编辑核心多标签页、文件读写与自动保存的设计思路打开一个文件把它显示在界面上用户改完再保存——这是编辑器的灵魂。但细节全在“打开”和“保存”这两个动词背后。3.1 多标签页如何实现TabLayout 自定义编辑器容器我用的是TabLayout配合一个自定义的ViewGroup来管理多个EditText实例。市面上大多数方案是ViewPager2但它惯于预加载相邻页面对文档编辑这种内存敏感场景不够友好。我要的是“滑到哪个就显示哪个不滑不加载”的效果于是干脆用TabLayoutMediator的思路自己写了一个轻量管理器。每个标签页对应一个EditorTab数据类public class EditorTab { private String filePath; private String originalContent; private String currentContent; private boolean isModified; private int cursorPosition; private StackEditAction undoStack; private StackEditAction redoStack; public EditorTab(String filePath) { this.filePath filePath; this.undoStack new Stack(); this.redoStack new Stack(); } }关键设计是原始内容originalContent和当前内容currentContent分开存储。判断一个文件是否被修改过直接compareTo两个字符串就好不需要维护一个脏标记位被各个回调反复改写。切换标签时的核心逻辑是保存当前标签的光标位置和编辑状态取出目标标签的文本填充到EditText。这里有个坑是EditText.setText()之后光标位置会回到 0所以必须在onTabSelected里通过setSelection恢复tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() { Override public void onTabSelected(TabLayout.Tab tab) { EditorTab currentTab editorTabs.get(tab.getPosition()); editText.setText(currentTab.getCurrentContent()); editText.setSelection(Math.min(currentTab.getCursorPosition(), editText.length())); currentTab.setCursorPosition(-1); } });3.2 文件读写的性能边界流、缓冲区和编码读文件我最开始的实现是FileInputStream加byte[]全部读入文件一超过 10MB 就直接内存溢出。后来换成了RandomAccessFile读前几个字节判断编码再用带 BOM 检测的InputStreamReader逐块读取private static String readTextFile(File file) throws IOException { byte[] bom new byte[4]; try (FileInputStream fis new FileInputStream(file)) { fis.mark(4); int read fis.read(bom); fis.reset(); String charset; if (read 3 (bom[0] 0xFF) 0xEF (bom[1] 0xFF) 0xBB (bom[2] 0xFF) 0xBF) { charset UTF-8; } else if (read 2 (bom[0] 0xFF) 0xFE (bom[1] 0xFF) 0xFF) { charset UTF-16BE; } else if (read 2 (bom[0] 0xFF) 0xFF (bom[1] 0xFF) 0xFE) { charset UTF-16LE; } else { charset detectCharset(file); } StringBuilder sb new StringBuilder(); try (BufferedReader reader new BufferedReader(new InputStreamReader(fis, charset))) { char[] buffer new char[8192]; int len; while ((len reader.read(buffer)) ! -1) { sb.append(buffer, 0, len); } } return sb.toString(); } }detectCharset的逻辑是基于统计的如果按 UTF-8 解析遇到非法字节序列就回退到 GBK。这个方案在中文环境里绝大部分场景是准的真正的硬需求是让用户能在设置里手动指定编码。写文件的策略我之前用的是全量覆盖现在改成了原子保存先把新内容写入同目录下的临时文件再renameTo替换原文件。这样做的好处是即使写入过程中程序崩溃原文件也不会损坏。代价是文件比较大的时候会有一次磁盘 io 峰值在会话启动时做好提示即可。3.3 自动保存与生命周期onPause 和 onStop 的抉择Android 的Activity生命周期告诉我们切到后台先走onPause然后走onStop。很多教程让你在onPause里保存数据我实测下来发现一个反直觉的结论onPause阶段 UI 还完全可见此时做耗时操作文件写入会直接影响转场动画的流畅度。所以我改成在onStop里做自动保存留给系统几百毫秒的喘息空间。更稳妥的方式是配合LifecycleObserverclass EditorLifecycleObserver implements LifecycleEventObserver { Override public void onStateChanged(NonNull LifecycleOwner source, NonNull Lifecycle.Event event) { if (event Lifecycle.Event.ON_STOP) { saveAllModifiedTabs(); } } }自动保存绝不能弹出对话框问用户“是否保存”那是最粗暴的设计。我用的是“静默保存 Toast 提示”并记录日志让用户可以追溯到哪个文件在什么时候被自动存过。如果保存失败比如存储空间不足再弹一个明确的通知。4. 文件管理器的实现权限、目录遍历与文件选择打开文件、保存文件都需要一个文件选择界面。Android 官方推荐用ACTION_OPEN_DOCUMENT去调系统文件选择器但我们的场景需要从编辑器内部展示最近的文件夹和文件树所以必须自己实现一个文件浏览器。4.1 分区存储与权限申请为什么这套代码在不同设备上都稳Android 10 之后强制分区存储应用不能随便访问外部存储的任意路径只能读自己专属目录、公共媒体目录以及用户主动选择的目录。文本编辑器这种应用要访问 Download、Documents 下的文件就得走MANAGE_EXTERNAL_STORAGE权限或者Storage Access Framework。我选择的策略是双轨并行对于应用自家目录和通过系统文件选择器授权的目录直接走正常的FileAPI。对于用户主动授予的“所有文件访问”权限则在引导页弹窗解释使用原因跳转到系统设置去开启MANAGE_EXTERNAL_STORAGE。private boolean checkAllFilesAccess() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { return Environment.isExternalStorageManager(); } else { int mode ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE); return mode PackageManager.PERMISSION_GRANTED; } }这里要特别注意MANAGE_EXTERNAL_STORAGE在 Play Store 上审核比较严格你的应用必须确实以文件管理为核心功能才合理。文本编辑器天然属于这个范畴但国内应用商店也会看你的权限声明是否匹配所以申请的时候要在AndroidManifest.xml里写清楚用途uses-permission android:nameandroid.permission.MANAGE_EXTERNAL_STORAGE tools:ignoreScopedStorage /4.2 目录遍历缓存避免每次打开文件树都卡顿文件列表最直接的方法是每进入一个目录就listFiles()一次。如果目录下有几千个文件或者是在网络驱动器上这种方式会卡顿两到三秒。我给文件浏览器加了一个缓存层把已经遍历过的目录结果放在LinkedHashMap里设置最大缓存 50 个目录超过就淘汰最久没访问的private static final int MAX_CACHE_SIZE 50; private LinkedHashMapFile, ListFile cache new LinkedHashMapFile, ListFile(MAX_CACHE_SIZE, 0.75f, true) { Override protected boolean removeEldestEntry(EntryFile, ListFile eldest) { return size() MAX_CACHE_SIZE; } };缓存失效的处理是基于文件最后修改时间的如果目录本身的lastModified()变了就认为条目过期重新遍历。这个方案在绝大多数场景下够用也比用FileObserver监听文件系统事件要简单得多。文件浏览器的 UI 我用的是RecyclerView每一个 item 展示文件名、大小、修改时间并且根据扩展名用不同的图标区分。需要提醒的是目录项和文件项要分开排序目录排在前面文件按名称的字典序排。这个看似不起眼的细节实际使用中体验差异很大。5. 差点翻车的地方中文乱码、大文件卡顿与撤销重做的坑这个项目做了大约三周其中有五天基本都在跟各种坑搏斗。有些问题回头看有清晰的解法但当时确实花了不少时间排查。5.1 中文乱码从“整个文件都是问号”到“只有部分字符异常”中文乱码这个事踩过的都知道不是简单切编码就能解决的。最开始我默认用 UTF-8 读所有文件遇到 Windows 上用记事本存的 GBK 文件打开全是“锟斤拷”。后来我加了 BOM 检测和统计式探测大部分场景解决了。但是还有一类情况UTF-8 文件里混入了 GBK 编码的片段比如某些老软件生成的日志。这种文件不管用哪种编码读都会有一小段乱码。我没有追求完美修复而是在编辑器状态栏显示当前文件的编码类型并提供“以 UTF-8 重新打开”“以 GBK 重新打开”“以自动检测重新打开”三个选项。这种取舍在真实世界是理性的让用户自己决定比任何启发式算法都可靠。5.2 大文件卡顿EditText 在 50MB 日志面前毫无招架之力把一篇 50MB 的日志文件直接塞进EditText现象是什么直接卡死内存飙到 500MB甚至直接 ANR。根因是EditText在展示和编辑过程中要建立SpannableStringBuilder内部维护了大量字符缓冲区和布局信息。我的应对方案是把“打开超大型文件”和“打开普通文件”分开处理。当文件大小超过 2MB 时采用只读 懒加载行号索引的预览模式先用RandomAccessFile快速读取文本的行偏移量然后每条可见行动态加载显示不一次性填充整个EditText。public class LargeFileReader { private RandomAccessFile raf; private ListLong lineOffsets; public LargeFileReader(File file) throws IOException { raf new RandomAccessFile(file, r); lineOffsets new ArrayList(); long pos 0; String line; while ((line raf.readLine()) ! null) { lineOffsets.add(pos); pos raf.getFilePointer(); } } public String readLineAt(int index) throws IOException { long start lineOffsets.get(index); raf.seek(start); return raf.readLine(); } }这样做之后50MB 的日志文件打开速度可以接受内存占用稳定在几十 MB 级别。但代价是编辑功能基本不能用只能预览和搜索。对日志查看这种场景这个取舍完全值得。5.3 撤销重做第三方库救不了你自己写一个事件栈反而更干净我前面提过最开始引入了undo-redo库用起来真的一言难尽它对TextWatcher的拦截不够精准连续输入的时候会被拆成一串碎片回退一格就崩。后来我干脆自己写了一个简单的“快照栈”每个编辑动作保存的是当前输入框的字符串快照栈深度控制在 50 层以内超过就丢弃最老的public class UndoManager { private final ListString undoStack new ArrayList(); private final ListString redoStack new ArrayList(); private static final int MAX_UNDO 50; public void pushSnapshot(String snapshot) { undoStack.add(snapshot); if (undoStack.size() MAX_UNDO) { undoStack.remove(0); } redoStack.clear(); // 新动作会清空重做栈 } public String undo(String current) { if (!undoStack.isEmpty()) { redoStack.add(current); return undoStack.remove(undoStack.size() - 1); } return current; } public String redo(String current) { if (!redoStack.isEmpty()) { undoStack.add(current); return redoStack.remove(redoStack.size() - 1); } return current; } }快照方案比记录 diff 更笨但更好理解关键是一个TextWatcher只允许 300ms 内最多入栈一次避免输入过程中的中间态被反复记录。这个计时器用Handler实现轻量且稳定。5.4 TextWatcher 的防抖处理为什么普通的 TextWatcher 会把人逼疯TextWatcher的afterTextChanged会随着每个字符的输入触发如果不做处理保存按钮会一路闪烁撤销栈会疯狂膨胀自动保存也会很混乱。我的处理是在afterTextChanged里设置一个定时器500ms 内没有新的输入变化才执行一次统一状态更新。private final Runnable updateRunnable new Runnable() { Override public void run() { updateModifiedState(); updateWordCount(); } }; Override public void afterTextChanged(Editable s) { handler.removeCallbacks(updateRunnable); handler.postDelayed(updateRunnable, 500); currentTab.setCurrentContent(s.toString()); }这种“防抖 汇总刷新”的模式在很多 Android 场景都通用尤其是搜索框自动联想、表单校验、以及编辑器的状态栏更新。6. 进阶方向语法高亮、编码探测与更多可能性核心功能稳定之后我开始琢磨怎么让编辑器好用起来。文本编辑器不是把文字显示出来就完了可扩展性才是它的价值所在。6.1 语法高亮用 SpannableString 而不是自定义 View 绘制提到语法高亮很多人第一反应是自定义 View 再自己画但那工程量太大。实际上 Android 原生提供的SpannableString配合正则表达式就能做一个够用的高亮引擎。思想是对每一行文本用正则匹配关键字、字符串、注释的模式然后用不同的ForegroundColorSpan上色private void highlightSyntax(Editable editable, String language) { if (editable.length() 100_000) { return; // 超大文本不做高亮避免卡顿 } clearSpans(editable); Pattern pattern LanguageRegistry.getPattern(language); Matcher matcher pattern.matcher(editable); while (matcher.find()) { int start matcher.start(); int end matcher.end(); if (matcher.group(1) ! null) { editable.setSpan(new ForegroundColorSpan(KEYWORD_COLOR), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } else if (matcher.group(2) ! null) { editable.setSpan(new ForegroundColorSpan(STRING_COLOR), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } else if (matcher.group(3) ! null) { editable.setSpan(new ForegroundColorSpan(COMMENT_COLOR), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } } }这个方案有一个必须注意的坑全量扫描高亮在文件变大之后极其消耗性能。我的优化思路是只对当前可见区域加 30 行的缓冲带做高亮滚动停止后再对新的可见区域做增量处理。这个策略做出来后10MB 以下的代码文件高亮完全流畅。6.2 编码探测库从“猜”到“有据可依”早期我用自己写的统计法猜编码准确率大约八九成。后来我发现了一个可靠的方案引入juniversalchardet一个从 Firefox 字符集探测算法移植而来的 Java 库依赖只有几十 KB但对 UTF-8、GBK、Big5、Shift-JIS 的识别准确率非常高。引入方式implementation com.github.albfernandez:juniversalchardet:2.4.0使用方式很直接UniversalDetector detector new UniversalDetector(null); byte[] buf new byte[4096]; try (InputStream in new FileInputStream(file)) { int len; while ((len in.read(buf)) ! -1) { detector.handleData(buf, 0, len); } } detector.dataEnd(); String charset detector.getDetectedCharset(); detector.reset();要强调的一点是任何探测库都不是 100% 可靠的所以编辑器必须保留“手动指定编码重新打开”的入口这是底线。6.3 还可以往哪个方向扩展书签、全文搜索替换、FTP 与 Git 联动我这个版本已经支持了基础的多标签、文件树、自动保存、编码探测和精简的语法高亮。如果再继续迭代我会优先做三件事第一是全文搜索替换。EditText原生不支持跨文件的搜索需要一个SearchManager来维护所有打开标签页的索引并提供一个集中结果面板。搜索结果的点击跳转会涉及到当前标签切换再加光标定位交互复杂度比想象中高不少。第二是书签与折叠。书签可以用同一套EditorTab数据模型扩展记录一组行号集合。折叠则需要在语法分析时构建缩进级别的树这对大文件性能又是一次挑战。第三是接入 Git。这个想法最近特别强烈。如果能够把JGit库嵌进来在文件树里显示每个文件的修改状态图标保存文件后允许提交单文件配合一个“查看最近变更”的面板那这个文本编辑器的实用性就会拉满。不过我还没动手主要是 JGit 的 API 对 Android 的支持细节比较繁琐需要专门腾出一段时间来调。写在代码之外这个项目给了我什么教训回头复盘这个项目最深刻的感受是文本编辑器没有“简单”版本每个看似基础的功能深入下去都是一个大坑集合。如果你也在考虑拿这个题目练手我的建议是先明确一个最核心的目标场景——你是需要一个能写代码和笔记的漂亮工具还是需要一个能打开超大文本文件并快速浏览的日志分析器这两个方向会把你引向完全不同的技术选型。我选择的是后者所以把很多精力花在了文件 IO 和大数据量适配上了。从工程角度看整个项目的状态管理是灵魂。EditorTab把所有编辑状态收敛到了数据类里UI 只负责展示和响应这让很多问题变得可追踪。如果你也想复刻这个项目先把自己的数据模型设计好再去碰 UI 和文件操作能省下大量调试时间。最后分享一个实际操作中发现的小技巧在 Android Studio 里开发这种纯 Java 的文本处理应用时可以把应用的逻辑核心文件读取、编码识别、撤销栈单独拆成一个纯 Java 模块不依赖任何 Android 类。这样你可以在本地直接写 JUnit 测试几十毫秒跑完一轮验证不用每次改一点逻辑都要打包上模拟器。开发效率几乎翻倍。这个习惯我后来带到了所有 Android 项目中是真的好用。
返回列表