一 Key 接入與 config.toml 骨架)
1. 長按選中彈菜單記筆記為什么原生方案在部分機型上會失效Android 里給 TextView 加一個「記筆記」的選中菜單看起來是個小需求但真正落地時會遇到兩個坑。第一個坑是系統(tǒng)自帶的ActionMode回調(diào)你通過setCustomSelectionActionModeCallback往菜單里塞了一個notes項在原生 Android 或者大部分 AOSP 機型上能正常顯示但到了某些深度定制的 ROM 上長按選中后彈出的菜單被系統(tǒng)接管你注入的 item 直接不出現(xiàn)。第二個坑是即使菜單出來了選中文本的起止 offset 在onActionItemClicked里拿到的時機和內(nèi)容也可能和你預期不一致尤其是 TextView 處于非聚焦狀態(tài)時getSelectionStart()返回 -1筆記內(nèi)容就寫了個空。這篇要解決的就是這條完整鏈路TextView 長按選中文本 → 彈出自定義菜單復制 / 記筆記→ 點擊記筆記 → 把選中內(nèi)容寫入本地筆記庫。同時我會把 TaoToken 的統(tǒng)一 Key 接入和config.toml骨架一起給出來因為很多同學在接大模型做「筆記摘要 / 標簽生成」時Key 管理一團亂正好借這個場景把配置規(guī)范一次講清楚。適合誰看正在做閱讀類、筆記類、資訊類 App需要在 TextView 上做自定義選中交互并且后續(xù)想把選中內(nèi)容丟給模型做二次處理的 Android 開發(fā)者。我試過直接用系統(tǒng)ActionMode的方案在小米、部分華為機型上確實會出現(xiàn)「記筆記」選項消失的情況所以下面會以自繪PopupWindow的SelectableTextHelper為主線把可復制的代碼和配置都給全。2. TaoToken 統(tǒng)一 Key 前置準備與 config.toml 骨架在寫筆記落庫之前先把「選中內(nèi)容 → 模型處理」這條鏈路的憑證準備好。TaoToken 的作用是提供一個統(tǒng)一的 API Key讓你在 Android 端調(diào)用模型對話、代碼補全等能力時不用為每個模型單獨維護一套鑒權(quán)。官網(wǎng)地址是 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骨架本地開發(fā)用local.properties注入CI 用環(huán)境變量覆蓋。下面是我實測下來比較穩(wěn)的config.toml骨架放在app/src/main/assets/config.toml運行時讀取# app/src/main/assets/config.toml # TaoToken 統(tǒng)一接入配置骨架 [api] # 統(tǒng)一網(wǎng)關(guān)地址不要帶末尾斜杠 base_url https://taotoken.net/api # 對話補全路徑 chat_path /v1/chat/completions # 請求超時秒 timeout_seconds 30 # 重試次數(shù) max_retries 2 [auth] # 運行時從 local.properties / 環(huán)境變量注入禁止提交真實 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 # 單條筆記最大字符數(shù)超出截斷 max_content_length 4000 # 是否自動生成標簽 auto_tag true對應(yīng)的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回調(diào)出去。先看菜單布局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的完整實現(xiàn)比較長關(guān)鍵點我拆開說。構(gòu)造函數(shù)里把 TextView 的文本轉(zhuǎn)成Spannable注冊長按、觸摸、點擊監(jiān)聽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這兩個方法處理了「行尾字符選不中」的經(jīng)典問題代碼在 excerpt 里已經(jīng)給全直接抄進TextLayoutUtil.java即可。selectText里用BackgroundColorSpan給選中區(qū)域上色同時把內(nèi)容存進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); } } }菜單里「記筆記」按鈕的點擊回調(diào)把內(nèi)容交給外部監(jiān)聽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(); }到這里選中彈菜單到筆記寫入的鏈路就通了。如果你還想在寫入前調(diào)模型生成摘要或標簽用第 2 節(jié)的config.toml讀 Key走https://taotoken.net/api的對話接口即可模型對話入口在 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 。4. 驗證請求從選中到筆記落庫的完整動作配置寫完了怎么確認真的跑通按下面四步走每步都有明確的觀察點。第一步啟動 App長按 TextView 任意位置。預期現(xiàn)象出現(xiàn)兩個圓形光標手柄選中區(qū)域被半透明色覆蓋上方彈出「復制 / 記筆記」菜單。如果菜單沒出現(xiàn)先檢查mTextView.setText(mTextView.getText(), TextView.BufferType.SPANNABLE)是否執(zhí)行Spannable是選中上色的前提。第二步拖動手柄調(diào)整選中范圍。預期現(xiàn)象菜單跟隨手柄位置移動選中內(nèi)容實時更新。這里依賴CursorHandle.update里的getHysteresisOffset如果拖動時手柄跳動或選不中行尾檢查TextLayoutUtil是否完整拷貝。第三步點擊「記筆記」。預期現(xiàn)象Toast 提示「已記筆記」菜單和手柄消失。在onTextSelect回調(diào)里打一行日志Log.d(NoteDebug, selected text , len text.length());第四步查詢數(shù)據(jù)庫確認落庫。用 Android Studio 的 App Inspection → Database Inspector打開note.db執(zhí)行SELECT id, content, createdAt FROM note ORDER BY createdAt DESC LIMIT 5;能看到剛才選中的文本就說明鏈路通了。如果要做模型處理在insert之前加一段請求用config.toml里的base_url和chat_path拼 URLHeader 用Authorization: Bearer keybody 里帶上選中文本。請求成功的返回結(jié)構(gòu)里取choices[0].message.content即可。5. 本篇常見錯排查菜單不顯示或點了沒反應(yīng)。最常見的原因是PopupWindow的setClippingEnabled(false)沒設(shè)導致菜單被父容器裁剪。另一個原因是showAtLocation的坐標算錯posY小于 0 時菜單跑到屏幕外代碼里已經(jīng)做了posY 16的兜底確認這段沒被刪。選中內(nèi)容為空或只有第一個字。檢查DEFAULT_SELECTION_LENGTH默認是 1長按后初始只選中一個字符需要拖手柄擴展。如果你希望長按直接選中一個詞可以在showSelectView里用getPreciseOffset配合getWordStart/getWordEnd擴展范圍。小米等機型上原生 ActionMode 方案失效。這就是本文改用自繪方案的原因。系統(tǒng)setCustomSelectionActionModeCallback在部分 ROM 上被攔截注入的 menu item 不顯示。自繪方案完全繞開系統(tǒng)菜單兼容性更好代價是要自己處理光標和滾動隱藏邏輯。滾動時菜單不消失。檢查mOnScrollChangedListener是否注冊isHideWhenScroll標志位是否在onPreDraw里正確復位。這段邏輯在 excerpt 的init()里確認addOnScrollChangedListener和addOnPreDrawListener都調(diào)用了。Key 讀取為空導致模型請求 401。確認local.properties里的TAOTOKEN_API_KEY沒有多余空格buildConfigField生成后重新 Build 一次。如果走環(huán)境變量確認 CI 的 secret 名稱和config.toml里的api_key_env一致。接入文檔在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有完整的鑒權(quán)和錯誤碼說明。筆記重復插入。onTextSelect回調(diào)在某些機型上可能觸發(fā)兩次插入前用內(nèi)容 時間戳做一次去重或者在回調(diào)里加一個isInserting標志位。6. 接入與排障入口如果你在接 TaoToken 的過程中遇到鑒權(quán)、路徑拼接、超時重試的問題直接去 API Keys 頁面確認 Key 狀態(tài)再對照接入文檔檢查 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 ??刂婆_可以看調(diào)用量和額度https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。驗證模型是否通用模型對話頁面發(fā)一條測試消息最快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 相關(guān)配置參考https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaude_code_anthropicutm_campaignrewrite 。最后留一個我踩過的坑SelectableTextHelper的destroy()一定要在onViewDetachedFromWindow里調(diào)用否則ViewTreeObserver的監(jiān)聽器會泄漏頁面來回切換幾次后內(nèi)存就上去了。把removeOnScrollChangedListener和removeOnPreDrawListener都加上這個問題就沒了。