Android TextView 自定义选中弹出菜单记笔记:TaoToken 统一 Key 接入与 config.toml 骨架

发布时间:2026/9/25 23:25:51
Android TextView 自定义选中弹出菜单记笔记:TaoToken 统一 Key 接入与 config.toml 骨架 1. 长按选中弹菜单记笔记为什么原生方案在部分机型上会失效Android 里给 TextView 加一个「记笔记」的选中菜单看起来是个小需求但真正落地时会遇到两个坑。第一个坑是系统自带的ActionMode回调你通过setCustomSelectionActionModeCallback往菜单里塞了一个notes项在原生 Android 或者大部分 AOSP 机型上能正常显示但到了某些深度定制的 ROM 上长按选中后弹出的菜单被系统接管你注入的 item 直接不出现。第二个坑是即使菜单出来了选中文本的起止 offset 在onActionItemClicked里拿到的时机和内容也可能和你预期不一致尤其是 TextView 处于非聚焦状态时getSelectionStart()返回 -1笔记内容就写了个空。这篇要解决的就是这条完整链路TextView 长按选中文本 → 弹出自定义菜单复制 / 记笔记→ 点击记笔记 → 把选中内容写入本地笔记库。同时我会把 TaoToken 的统一 Key 接入和config.toml骨架一起给出来因为很多同学在接大模型做「笔记摘要 / 标签生成」时Key 管理一团乱正好借这个场景把配置规范一次讲清楚。适合谁看正在做阅读类、笔记类、资讯类 App需要在 TextView 上做自定义选中交互并且后续想把选中内容丢给模型做二次处理的 Android 开发者。我试过直接用系统ActionMode的方案在小米、部分华为机型上确实会出现「记笔记」选项消失的情况所以下面会以自绘PopupWindow的SelectableTextHelper为主线把可复制的代码和配置都给全。2. TaoToken 统一 Key 前置准备与 config.toml 骨架在写笔记落库之前先把「选中内容 → 模型处理」这条链路的凭证准备好。TaoToken 的作用是提供一个统一的 API Key让你在 Android 端调用模型对话、代码补全等能力时不用为每个模型单独维护一套鉴权。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。你需要先拿到 Key入口在 API Keys 页面https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 。拿到之后不要硬编码进BuildConfig而是走一个config.toml骨架本地开发用local.properties注入CI 用环境变量覆盖。下面是我实测下来比较稳的config.toml骨架放在app/src/main/assets/config.toml运行时读取# app/src/main/assets/config.toml # TaoToken 统一接入配置骨架 [api] # 统一网关地址不要带末尾斜杠 base_url https://taotoken.net/api # 对话补全路径 chat_path /v1/chat/completions # 请求超时秒 timeout_seconds 30 # 重试次数 max_retries 2 [auth] # 运行时从 local.properties / 环境变量注入禁止提交真实 Key api_key_env TAOTOKEN_API_KEY # 请求头字段名 header_name Authorization header_prefix Bearer [model] # 默认模型按需替换 default claude-sonnet # 笔记摘要场景用的模型 note_summary claude-sonnet # 温度 temperature 0.3 max_tokens 1024 [note] # 笔记本地库名 db_name note.db # 单条笔记最大字符数超出截断 max_content_length 4000 # 是否自动生成标签 auto_tag true对应的local.properties里加一行这个文件本来就在.gitignore里TAOTOKEN_API_KEYsk-你的真实key然后在build.gradle里把它读进BuildConfigandroid { defaultConfig { def localProps new Properties() def localFile rootProject.file(local.properties) if (localFile.exists()) { localProps.load(new FileInputStream(localFile)) } buildConfigField String, TAOTOKEN_API_KEY, \${localProps[TAOTOKEN_API_KEY] ?: }\ } }这样 Key 只存在于本地和 CI 的 secret 里代码仓库里永远看不到明文。如果你后面要做长期编码或 Agent 场景可以看 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 它把额度管理和 Key 复用做得更省心。3. 可复制配置SelectableTextHelper 自绘菜单 笔记落库原生ActionMode方案在定制 ROM 上不可靠所以这里用自绘PopupWindow的SelectableTextHelper。核心思路是拦截 TextView 的长按和触摸事件自己计算选中范围自己画光标手柄自己弹菜单。菜单里放「复制」和「记笔记」两个按钮点「记笔记」时把mSelectionInfo.mSelectionContent回调出去。先看菜单布局layout_operate_windows.xml注意用CardView包一层圆角和阴影更自然?xml version1.0 encodingutf-8? RelativeLayout xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:apphttp://schemas.android.com/apk/res-auto android:layout_widthwrap_content android:layout_heightwrap_content androidx.cardview.widget.CardView android:layout_widthwrap_content android:layout_heightwrap_content app:cardBackgroundColorcolor/white app:cardCornerRadius6dp app:cardElevation4dp LinearLayout android:layout_widthwrap_content android:layout_heightwrap_content android:orientationhorizontal TextView android:idid/tv_copy android:layout_widthwrap_content android:layout_heightwrap_content android:padding10dp android:text复制 android:textColorcolor/black / View android:layout_width0.5dp android:layout_height20dp android:layout_gravitycenter android:backgroundcolor/gray_DDDDDD / TextView android:idid/tv_note android:layout_widthwrap_content android:layout_heightwrap_content android:padding10dp android:text记笔记 android:textColorcolor/black / /LinearLayout /androidx.cardview.widget.CardView /RelativeLayoutSelectableTextHelper的完整实现比较长关键点我拆开说。构造函数里把 TextView 的文本转成Spannable注册长按、触摸、点击监听public SelectableTextHelper(Builder builder) { mTextView builder.mTextView; mContext mTextView.getContext(); mSelectedColor builder.mSelectedColor; mCursorHandleColor builder.mCursorHandleColor; mCursorHandleSize TextLayoutUtil.dp2px(mContext, builder.mCursorHandleSizeInDp); init(); } private void init() { mTextView.setText(mTextView.getText(), TextView.BufferType.SPANNABLE); mTextView.setOnLongClickListener(v - { showSelectView(mTouchX, mTouchY); return true; }); mTextView.setOnTouchListener((v, event) - { mTouchX (int) event.getX(); mTouchY (int) event.getY(); return false; }); mTextView.setOnClickListener(v - { resetSelectionInfo(); hideSelectView(); }); mOperateWindow new OperateWindow(mContext); }选中范围的计算靠TextLayoutUtil.getPreciseOffset和getHysteresisOffset这两个方法处理了「行尾字符选不中」的经典问题代码在 excerpt 里已经给全直接抄进TextLayoutUtil.java即可。selectText里用BackgroundColorSpan给选中区域上色同时把内容存进mSelectionInfo.mSelectionContentprivate void selectText(int startPos, int endPos) { if (startPos ! -1) mSelectionInfo.mStart startPos; if (endPos ! -1) mSelectionInfo.mEnd endPos; if (mSelectionInfo.mStart mSelectionInfo.mEnd) { int temp mSelectionInfo.mStart; mSelectionInfo.mStart mSelectionInfo.mEnd; mSelectionInfo.mEnd temp; } if (mSpannable ! null) { if (mSpan null) mSpan new BackgroundColorSpan(mSelectedColor); mSelectionInfo.mSelectionContent mSpannable.subSequence(mSelectionInfo.mStart, mSelectionInfo.mEnd).toString(); mSpannable.setSpan(mSpan, mSelectionInfo.mStart, mSelectionInfo.mEnd, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); if (mSelectListener ! null) { mSelectListener.onTextSelected(mSelectionInfo.mSelectionContent); } } }菜单里「记笔记」按钮的点击回调把内容交给外部监听contentView.findViewById(R.id.tv_note).setOnClickListener(v - { if (mNoteBookClickListener ! null) { mNoteBookClickListener.onTextSelect(mSelectionInfo.mSelectionContent); } SelectableTextHelper.this.resetSelectionInfo(); SelectableTextHelper.this.hideSelectView(); });在 Activity 里这样用mSelectableTextHelper new SelectableTextHelper.Builder(mManusTv) .setSelectedColor(getResources().getColor(R.color.color_tv_theme_transparent15)) .setCursorHandleSizeInDp(20) .setCursorHandleColor(getResources().getColor(R.color.colotBtnTheme)) .build(); mSelectableTextHelper.setOnNotesClickListener(content - { String text content.toString().trim(); if (TextUtils.isEmpty(text)) return; // 写入笔记库 NoteRepository.getInstance().insert(new Note(text, System.currentTimeMillis())); Toast.makeText(this, 已记笔记, Toast.LENGTH_SHORT).show(); });笔记落库用 Room 最省事实体和 DAO 骨架Entity(tableName note) public class Note { PrimaryKey(autoGenerate true) public long id; public String content; public long createdAt; public Note(String content, long createdAt) { this.content content; this.createdAt createdAt; } } Dao public interface NoteDao { Insert long insert(Note note); Query(SELECT * FROM note ORDER BY createdAt DESC) ListNote queryAll(); }到这里选中弹菜单到笔记写入的链路就通了。如果你还想在写入前调模型生成摘要或标签用第 2 节的config.toml读 Key走https://taotoken.net/api的对话接口即可模型对话入口在 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 。4. 验证请求从选中到笔记落库的完整动作配置写完了怎么确认真的跑通按下面四步走每步都有明确的观察点。第一步启动 App长按 TextView 任意位置。预期现象出现两个圆形光标手柄选中区域被半透明色覆盖上方弹出「复制 / 记笔记」菜单。如果菜单没出现先检查mTextView.setText(mTextView.getText(), TextView.BufferType.SPANNABLE)是否执行Spannable是选中上色的前提。第二步拖动手柄调整选中范围。预期现象菜单跟随手柄位置移动选中内容实时更新。这里依赖CursorHandle.update里的getHysteresisOffset如果拖动时手柄跳动或选不中行尾检查TextLayoutUtil是否完整拷贝。第三步点击「记笔记」。预期现象Toast 提示「已记笔记」菜单和手柄消失。在onTextSelect回调里打一行日志Log.d(NoteDebug, selected text , len text.length());第四步查询数据库确认落库。用 Android Studio 的 App Inspection → Database Inspector打开note.db执行SELECT id, content, createdAt FROM note ORDER BY createdAt DESC LIMIT 5;能看到刚才选中的文本就说明链路通了。如果要做模型处理在insert之前加一段请求用config.toml里的base_url和chat_path拼 URLHeader 用Authorization: Bearer keybody 里带上选中文本。请求成功的返回结构里取choices[0].message.content即可。5. 本篇常见错排查菜单不显示或点了没反应。最常见的原因是PopupWindow的setClippingEnabled(false)没设导致菜单被父容器裁剪。另一个原因是showAtLocation的坐标算错posY小于 0 时菜单跑到屏幕外代码里已经做了posY 16的兜底确认这段没被删。选中内容为空或只有第一个字。检查DEFAULT_SELECTION_LENGTH默认是 1长按后初始只选中一个字符需要拖手柄扩展。如果你希望长按直接选中一个词可以在showSelectView里用getPreciseOffset配合getWordStart/getWordEnd扩展范围。小米等机型上原生 ActionMode 方案失效。这就是本文改用自绘方案的原因。系统setCustomSelectionActionModeCallback在部分 ROM 上被拦截注入的 menu item 不显示。自绘方案完全绕开系统菜单兼容性更好代价是要自己处理光标和滚动隐藏逻辑。滚动时菜单不消失。检查mOnScrollChangedListener是否注册isHideWhenScroll标志位是否在onPreDraw里正确复位。这段逻辑在 excerpt 的init()里确认addOnScrollChangedListener和addOnPreDrawListener都调用了。Key 读取为空导致模型请求 401。确认local.properties里的TAOTOKEN_API_KEY没有多余空格buildConfigField生成后重新 Build 一次。如果走环境变量确认 CI 的 secret 名称和config.toml里的api_key_env一致。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有完整的鉴权和错误码说明。笔记重复插入。onTextSelect回调在某些机型上可能触发两次插入前用内容 时间戳做一次去重或者在回调里加一个isInserting标志位。6. 接入与排障入口如果你在接 TaoToken 的过程中遇到鉴权、路径拼接、超时重试的问题直接去 API Keys 页面确认 Key 状态再对照接入文档检查 Header 和 body 格式。API Keys 入口https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。控制台可以看调用量和额度https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。验证模型是否通用模型对话页面发一条测试消息最快https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 。如果你后面要把这套选中笔记的能力接到 Claude Code 或 Agent 工作流里Coding Plan 的额度复用会更合适https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。ClaudeCodeAnthropic 相关配置参考https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaude_code_anthropicutm_campaignrewrite 。最后留一个我踩过的坑SelectableTextHelper的destroy()一定要在onViewDetachedFromWindow里调用否则ViewTreeObserver的监听器会泄漏页面来回切换几次后内存就上去了。把removeOnScrollChangedListener和removeOnPreDrawListener都加上这个问题就没了。