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