之前 QingjianInputMethodService 有 820 行,超过单文件 800 行的上限;游戏键盘的判断、浮层与按钮, 以及云联想、释义兜底、快捷翻译、语音识别的配置下发都写在服务里。 - GameMode:哪个应用用游戏键盘、建浮层、触摸区、拖动栏按钮与横屏应用登记;服务经 GameMode.Host 提供包名、写设置与重建键盘 - CloudServices:云端与语音识别配置交给引擎,配置页改了跟着换(与剪贴板的局域网同步无关) - 服务里「写设置时不重建键盘」统一成 writeSettings,服务现在 726 行 不改行为;拖动栏「换回普通键盘」重建前也先收起面板,与功能面板里的开关一致。 测试:真机(小米,MIUI)试打框弹键盘正常;浏览器里功能面板开、关游戏键盘,设置里的应用列表随之增删、勾选状态正确;横屏浮层没在游戏里验
31 KiB
Android 实现要点
apps/android 的功能怎么接到 Core、各处的取舍。目录与构建见 apps/android/README.md,用户怎么用见 user/platforms/android.md。
键盘
- 布局:拼音 26 键 / 九宫格 / 双拼 / 五笔 86 / 注音大千,加上英文 26 键与日语罗马音,在键盘选择面板里切。英文只切到英文(全键盘),中文布局不变。
- 全键盘:字母键角标(首排数字、二三排符号,中文 H J 是中文引号),长按弹 大写 / 角标符号 / 小写;中文底排 符 / 123,英文底排 !?# / 123(常用符号页)。
按住空格左右滑挪光标(
space_cursor,缺省开;横向挪过 14dp 且比竖向多才算,起手在空格上,与字母键起手的滑行、上滑分得开;组句中不响应:先commitRaw再挪会把没选的字母上屏;拼音里的光标靠点拼音行挪,见下条)、按住不动进语音(见下文「语音输入」);回车键按输入框的动作写 发送 / 搜索 / 前往 / 下一项 / 完成(EnterLabel),多行框画回车图标。 - 点拼音行挪光标(#881):渲染器给出拼音行各字之间的位置(
StripMeta.preedit_edges),候选条点在拼音行里按最近的缝走Native.moveCaret, CoreEngine::move_cursor_to_marked换回缓冲区光标(全拼按字母、双拼展开全拼时按最近的音节边界、显示敲的键时按键;纠错 / 辅码 / 注音 / 九宫格对不上,不挪);Frame.preedit_cursor让候选条把光标画在中间。光标在开头时Session::backspace先挪到末尾再删(没有方向键挪回去,不然退格就卡住了)。 - 九宫格拼音行的下划线:
Frame.preedit_pinned是拼音行里定下的音节(字符起止,前面留着的词 +Engine::nine_key_pinned),strip::preedit把它切成PreeditStyle::Pinned一段。 - 删除键上划清空(
flick_clear,缺省开):删除键按下照旧先删一个字并起连删,同时onBackspaceDown记下按下那一刻光标前的文字(最多MAX_CLEAR= 5000);往上划够上滑距离就停掉连删,抬手Composer.clearBefore;连删已经起来(REPEAT_DELAY_MS之后)这一笔就作废,不当清空。 按下时在组句只清拼音;否则deleteSurroundingText删光标前的文字,Native.cleared让会话在补全里放「撤销」(光标前还是空的就一直留着),撤销放回按下那一刻的文字,所以按下时删的那个字(按键事件可能还没到应用)也回来。 - 上滑(
flick_symbols,缺省开,#882):只看方向不看列。手指离开按下那一点 10dp 起就得一直在往上的扇形里(横向不超过往上的 1.2 倍,约偏离竖直 50°,含历史点), 往上过 18dp 抬手出Key.flick(长按不滑直接抬手那一项:字母键是角标,九宫格是数字),出的总是按下的键;中途偏出扇形(横走、往下、拐弯)这一笔就作废,交给滑行或点按。 两者同时成立时上滑优先(一路往斜上走的滑行词很少)。手指一进扇形就撤掉等着的长按:慢慢往斜上划时长按先到点弹气泡、手指再一歪就选上了气泡左边的大写,这是 #882 说的「误触大写」。 - 长按出大写(
long_press_upper,缺省关):字母键长按不滑直接抬手出气泡第 0 项(大写)而不是角标;上滑仍是角标,两条手势各管各的,不随状态换含义。 - Shift 是三态
ShiftState:点一下单次,350 ms 内再点锁定(单次键亮着,锁定再加右上角一个品牌色小点;微软双拼的;同样显示),锁定时再点解锁;单次的出过一个字母就熄。拼音、日语组句中 Shift 不响应(大写字母在 Core 另有含义),英文组句中可以。 - 英文键盘组句时字母按 Shift 带大小写进引擎(Core 的英文模式保留大小写,
Compa出 Company);空格、数字、标点前commitRaw落下敲的字母,不顶首选——首选可能是补全或纠错出的词,验证码、型号会被换掉。要词点候选。 - 键位排法
Settings.Arrangement:缺省STANDARD一直不动(肌肉记忆),改了键位置的进LEFT_THUMB(「左手便捷」):符号面板分类竖排在左(Tabs.Style.COLUMN)、左下返回、右列只剩删除 / 锁定。 - 「123」进数字键盘:左列可拖的符号,中间 3×3 数字,右列 删除 / . / @。输入框是电话、数字、日期时间类(
EditorInfo.wantsNumbers)时onStartInput直接换到这一页;abandon每次换输入框都回字母页,离开时不用另记原来的键盘。 - 九宫格是 Core 的
nine_key方案(design/nine-key.md):左列是同码的拼音,点一下定下音节,不在组句时放标点。 数字键长按弹abc2ABC(1键是1@-_/),组句中选小写字母就把这一位定成这个字母。九宫格的查询不走模糊音。 - 注音大千按电脑大千键位排四排,键面画注音、进引擎的是大千的 ASCII 键(Core 的
zhuyin::map_key)。空格走Session::space:缺声调时是一声,否则上屏首选。 - 双拼九套方案与「输入时显示按键」(
shuangpin_raw_preedit)同 PC;键上印的声韵母从 Core 的方案表取(Native.shuangpinKeycaps),用;当韵母的方案二排末尾多一个;键;声韵母小字可关(shuangpin_hints)。 微软双拼照手机键盘的习惯(搜狗等)把;放在 Shift 位(Key.Letter.flickShift):点按永远是;、上滑是 Shift,不按组句状态换含义;二排回到九个键。 - 五笔走 Core 的形码管线,码表是随包的
assets/schemes/wubi86/table.tsv(随包时改名wubi86.tsv,第一次切到五笔时读);「五笔拼音混输」开着时拼音也能打。 - 日语罗马音(测试版)走 Core 的
RomajiComposer,不经拼音引擎;候选是小词表汉字、平假名、片假名、原样罗马音,回车上屏平假名。 - 中英键按一下在一对语言间切(缺省 中 ↔ 英),长按竖排弹出全部语言。「换语言时保留中文」开着时另一边总是中文;当前语言与另一边存在设置里。
- 全面屏适配开着时手势条那一截左右各放一个键(切换输入法 / 语音 / 剪贴板 / 收起键盘 / 不放),两边不能一样。
- 底边让出的导航栏高度在建视图时量一次(
InputPanel),用户换了手势 / 三键导航时输入法进程不一定重建视图,三键的导航栏会盖住最下一排;所以每次弹键盘(onStartInputView)重量一次,变了就重建。 - 日志:Rust 的 tracing 同时写 logcat 与
files/logs/ime.<日期>.log(按天、留 7 天,不经后台线程,没接住的异常记完再退);Kotlin 经AppLog→Native.log写同一份。QingjianApp一起来就装上,并补记getHistoricalProcessExitReasons里上回之后的退出原因(Android 11 起)。文件缺省 info,开了详细日志才记 debug(带打的字);开关存在单独的diagnosticsprefs,放设置那份会触发键盘重建。「数据 → 日志 → 打包日志」见LogPack:日志 + 设置清单(抹掉像密钥的项)+ 机型、省电、输入法、随包数据清单。 - 密码、网址、邮箱与非文本输入框不过引擎。
inputType整个是 0 才算非文本;类别是 0 却带文本标志的(抖音直播间报0x20000)照常组句。
候选条
- 由 Rust 渲染器(
qingjian-render的Layout::Strip)按设置里的主题画成位图贴上(见下文「外观」),与桌面候选窗同一套主题;emoji 渲染器画不出(系统 emoji 是 COLRv1),壳在词的位置上补画;位图里 emoji 候选只用全角空格占位,渲染器也画得出彩色 emoji 时才不会叠成两份(#902,报在 Android 17 上)。 真机结果见 design/rendering.md「Android 候选条」。 - 长按候选弹气泡:左右排开译词,滑到哪条抬手上屏,对应桌面端修饰键 + 数字。删得掉的候选(Core
CandidateKind::forgettable)末尾多一项「删除」(Session::forget,对应桌面端 ⇧ + 数字),结果用 toast 提示; 没有译文时气泡里只有「删除」,不预选,得往上滑进气泡才算,免得长按松手误删。 - 右端 ⌄ 展开全部候选:盖住键盘,按字宽排、每行撑满、上下滑,右列 返回 / 删除 / 重输。
- 分次选词留在组句里(Core 的
hold_selections,键盘设置开关,缺省开):选词没吃完拼音时commit回空串,留着的词经Query::marked_text显示在拼音前面,退格由 Core 撤回、回车take_raw带上留着的词。 空格在有候选时只选首选(留着就接着选),没有候选才原样上屏;标点、符号这类「顶首选」要结束组句,壳选首选后若还在组句就commitRaw,留着的词连同剩下的拼音一起落下。 - 不在组句时光标前是「用户名@」,候选条列常见邮箱后缀(Core 的
email_completions,经Session::complete);中文组句中按 @,敲的字母原样落下当用户名。 - 光标前末尾是一道算式(数字键盘上直接上屏的
3+4、3×4=)时,同一条路给结果(Core 的calculation_completions):帧的replace是上屏前要删掉的字符数(换成结果那条),壳deleteSurroundingText后再上屏。壳只在光标前最后一个字是数字、)或=时才问。
快捷输入
rq/sj/xq、v1+2、u4e00都是 Core 的快捷候选。手机上数字与运算符不在字母键盘上:帧带expression(Engine::expression_mode或unicode_entry),为真时「123」「符号页」不顶首选,数字键盘上的数字与+ - * / ( ) . ^进缓冲区(× ÷ 换成 * /),=上屏「算式=结果」那条。- 九宫格是数字串,
77不认rq;拼音列或长按把字母都定下来(缓冲区成了r'q)才出。全拼出了「日期 / 时间 / 星期」时 Core 把同一组写法插在这个词后面、它的 emoji 之前(九宫格7474的「日期」常排第四,所以看前五个词)。
输入设置
键盘设置「输入」一节与 PC 的同名项对应,整份 InputOptions 经 Session::set_options 交给 Core,名字同 PC 配置:
- 模糊音:
[fuzzy]九条。 - 拼写纠错:
spelling_correction(同 PC 的[general]),Engine::set_spelling_correction。 - 中文用全角标点:中文键盘的逗号句号与拼音列 ,。?!: 键带着对应的半角字符,上屏前经
Engine::punctuate转换(引号成对、数字后的点保持半角);关了出半角并note_passthrough。 符号页里的标点是明确挑的字符,不转换。 - 打拼音时也给英文词 / 中文排在英文词前面:
english_in_chinese/chinese_first。 - 英文键盘给候选:关了英文字母直接上屏,壳里判断,Core 没有对应项。
- 学习输入习惯:
learning,即Engine::set_learning;私密输入另走Session::set_private,由输入框的IME_FLAG_NO_PERSONALIZED_LEARNING决定(浏览器无痕标签页会带),对应 PC 上应用声明的私密输入框。Core 的私密输入不学习、不记生词与例句、不发云端,输入统计照记(同 PC)。 - 繁体字形:
traditional_variant(taiwan/hongkong/standard)。功能面板的「繁体」仍是开关,开着时按这里的字形出(Session::set_traditional用会话记着的字形)。 - 附加词库:
domains/disabled_dictionaries,同 PC 的[dictionaries]。会话每次交设置时按开关列出该挂的文件(路径、修改时间、大小),与上次一样就不动,变了重新set_extra_dictionaries。 - 学习语言:
learning_language(off不显示译文)。装配时不装学习语言的释义表,壳交设置时Session::set_options按语言装(随包表叠个人释义表),语言变了才换,换前先flush_learning。 几门语言的释义表全部随包(glossary-*.qj共约 65 MB),离线即换;等级表levels-en.tsv/levels-ja.tsv也随包。
PC 上只对实体键盘有意义的项(Shift + 字母、切换键、翻页键、高亮移动键、状态条、标点先上屏候选的开关)不搬到手机。
面板
面板盖住候选条与键盘那一整块,高度跟键盘走:功能面板(常用功能宫格 + 就地改的开关,「繁体」在这里,字形按键盘设置里选的)、定制工具栏(ToolbarTool 名字逗号分隔存 toolbar,没存过是缺省那七个,空串是全移除;面板里改的先留在面板上,点 ✓ 才写设置,免得每点一下都重建键盘)、键盘选择、分类符号、表情(Emoji / 颜文字,能接肤色的排出五档滑选)。
符号与表情的内容是 Core 的面板目录(Native.panelCatalog),与桌面端符号面板同一份;「常用」按用过的排(Core 的 common_items + PanelUsage),每次打开取一次。
符号一行排偶数格(一字一格六格,网格窄于 6 × 40dp 排四格;宽的四格或两格):目录里成对的括号引号从偶数位起,偶数列就不会把一对拆到两行。
词库
- 领域词库是
data/generated/dicts/的 11 本.qj(约 7.5 MB),stageData放进assets/data/dicts/,BundledData递归解到files/data/dicts/;缺省只开成语,同 PC。 - 导入走 SAF(
ACTION_OPEN_DOCUMENT,*/*多选:TSV 与 Rime 文件的 MIME 各家文件管理器给得不一样)。壳按原文件名拷到缓存目录,Rust 的store::dictionaries交qingjian_dictionary::import转成.qj放进files/user/dicts/,再删掉拷贝。导入与删除后改设置里的dictionaries_stamp,开着的键盘重建、重交选项、会话按文件重挂。 - 「删除」直接删文件:PC 的「移除」是挪进
removed/,手机上用户碰不到这个目录,挪过去等于留垃圾。 - 有的厂商文件选择器按自己的文件索引列文件,
adb push进去的要先发MEDIA_SCANNER_SCAN_FILE广播才搜得到。 - 通讯录(#876,设计见
docs/design/contacts.md):contacts/ContactsSync在设置开着、有READ_CONTACTS时后台读Contacts与Data(电话、邮箱)两张表,回主线程交Native.setContacts;55 个联系人读加建词库约 20 ms。 MIUI 上只给「仅在使用中允许」时,输入法服务在后台启动那次读到空结果(权限仍显示允许),所以读到零个不算读过,onStartInputView里再读(retryIfEmpty)。 shell 的content delete在 MIUI 上删不掉联系人(不报错、不生效),真机测试加的联系人要在通讯录 App 里删。
剪贴板与常用语
- 系统剪贴板复制了文字或图片由
ClipboardWatcher记进历史:默认输入法读剪贴板不要权限;标了敏感的不记;可关、可设最多 100 / 500 / 1000 / 5000 条。 监听在输入法没起来时收不到,所以键盘每次弹出也读一遍;同一份剪贴板按ClipDescription.timestamp只记一次(时间戳存在clipboardprefs,进程重起也认得),不然用户删掉的那条只要还在系统剪贴板里,下次弹键盘又被记回来。 存取在 Rust 侧qingjian_learning::ClipboardHistory(clipboard.jsonl,图片另存clipboard-images/)。 - 像代码的文字(Core
code::looks_like_code)用等宽字体原样显示,「代码高亮」按 Corecode::highlight上色。 - 图片走
commitContent发出去(ClipboardImageProvider只读临时授权,query要给OpenableColumns,否则有的应用把 content:// 地址当文字插进去);输入框不收图片就放回系统剪贴板。 - 拆词(Core
text_pieces切块)可点选或横滑连选,选着的实时放进输入框、处在编辑中,返回或完成确认上屏。 - 常用语(
PhraseBook,phrases.json)按分组列,卡片带输入码;带输入码的挂成 Core 自定义短语,打码直接出。输入码最多 32 个字母,跟 Core 自定义短语的上限一致。
翻译选中的文字
- PC 的「翻译选中文字」(快捷键读选区)在手机上改成系统文字选择菜单里的「青简翻译」:
ProcessTextActivity接ACTION_PROCESS_TEXT,透明窗口 + 底部卡片(主题Theme.Qingjian.Sheet),不进最近任务。 原应用没给EXTRA_PROCESS_TEXT_READONLY时多一个「替换」,译文经setResult的EXTRA_PROCESS_TEXT交回去。 - 翻译走
Engine::request_translation,方向由 Core 按文字判断。Activity 不借输入法的引擎(输入法不一定是青简,也不该打断它的会话),另装一个轻量 Engine(assemble_translation:主词库 mmap + 学习语言释义表,几毫秒),用同一套setCloud/translate/pollTranslation出口。 - 失败(出错、回包解析不了、模型没给内容)由
qingjian-predict的 worker 回一条空结果,壳当场显示红字「翻译失败」并只给「重试」,与译文分开,不必等超时;快捷翻译条的失败状态同样是红字。 - 自家 App 的输入框要列出文字处理项得在清单声明
<queries>(Android 11 起的包可见性)。有的厂商系统在青简试打框的选择菜单里只列「搜索 / 分享」,别的翻译应用也不出,真机上用am start -a android.intent.action.PROCESS_TEXT验的。
浮动编辑条与快捷翻译
- 常用语编辑框与快捷翻译条都是
FloatingEditor:开着时 Composer 的输入连接换成它的,键盘打的字进编辑条;打输入码时换英文全键盘。 编辑条里打的字不进应用,开着时Engine::set_metered(false),不计今日字数。 - 快捷翻译:原文(不含组句中的拼音)停顿 450 ms 后经
Engine::request_translation发云端(汉字为主译成学习语言、外文译成中文),每 100 ms 取一次; 译文用组合文字放进外面的输入框,改原文跟着换,确定或回车落定,取消撤掉。 - 云端是
qingjian-predict的CloudPredictor,开rustlsfeature(ring +webpki-roots,不用平台证书库);云联想关着时配置只开翻译,平时打字不联网。rustls的 crypto provider 要在 async-openai 建客户端之前装好,见 crate-notes.md 的 predict 一节。不记接口用量:用量文件靠文件锁合并,安卓上lock()不支持。 - 密钥由
SecretStore用 Android Keystore 里生成的 AES-256 钥匙(不可导出、卸载即毁)做 AES-GCM 加密后只存密文;明文只在调 JNI 时临时拼进 JSON,Rust 侧不派生Debug、不进日志。 应用关了系统备份;快捷翻译、翻译选中的文字、释义兜底与云联想要联网(INTERNET权限),「只在 Wi-Fi 下使用」读网络类型要ACCESS_NETWORK_STATE。 - 释义兜底(键盘设置「云端大模型」里的开关,缺省关,同 PC 的
CloudGlossFiller):会话set_gloss_fill用同一份云端配置起CloudGlossFiller,Core 在上屏时判断「释义表查不到、不是私密输入」才发那个词;Handle::operate每次操作前poll_glosses,补好的释义当场进个人释义表(user-glossary-<语言>.tsv),下一帧就有译词。 PC 上它随云联想一起开,手机上单独一个开关;PC 的「生词本整句翻译」(vocabulary_translation)会发整句,手机不开。 服务在键盘重建、引擎装好、云端配置(cloud那份 prefs)改了时同步;Engine.setGlossFill按配置摘要去重,同一份不重交(每交一次 Rust 侧都另起一条线程)。 云端没填好时设置页不放开关,换成一行说明。
云联想与整句补全
- 键盘设置「云端大模型」里的开关,缺省关,同 PC 的自动联想:
CloudSettings.predict打开PredictConfig的automatic与sentence,两个云端词槽,停键 500 ms,两次请求至少隔 1 s,超时 10 s;生词本整句翻译不开。 - 会话
refresh每次查完候选就request_prediction(防抖在 worker 里),壳在一段组句的第一帧之后读光标前 64、后 32 个字交setSurrounding,交了按带上下文的再要一次。 组句中 Composer 每 150 mspollPrediction,结果插在本地首选之后:先整句补全,再云端词,与本地重复的不要;选中整句走accept_prediction。 - 九宫格敲的是数字,Core 用这次查询的拼音行(
QuerySnapshot.pinyin,九宫格时取typed_display)问云端,没查过这段就不发。 - 候选条的
strip_cell给cloud候选前加小云朵、字用@cloud色(同 PC 候选窗);展开的网格里同样画云朵(PanelIcons.Icon.CLOUD),字用Palette.cloud。 - 「只在 Wi-Fi 下使用」缺省开:
service/CloudServices.sync在键盘建起、每次开始输入和云端配置改了时看Network.onWifi(Wi-Fi 或有线,开 VPN 也认得出),不在就退回只翻译。 打字途中换网络要到下次开始输入才跟上。快捷翻译与释义兜底不看这个开关。 - 工具框(快捷翻译、常用语编辑)开着时会话不发、不收联想(
set_metered(false)时tools_open),快捷翻译的译文不被组句联想作废。
App
启动页底部 tab 切 首页 / 生词本 / 我的,配色 AppColors 与键盘分开。首页是没切到青简时的切换卡、输入统计看板(Native.statsDetail 传 off 不读生词本:今天 / 近 7 天 / 累计、最近 30 天柱状图、四个比例)、常用功能与试打框;
二级页「键盘设置」「输入统计」(点看板进,「我的」里不再单列入口)「关于」。「我的」页的累计卡仍用 Native.usageStats。
- 生词本页(
Native.vocabulary)一次交{language, filter, level, change}:Rust 侧先改(标认识 / 删除,写回user-vocab.tsv)再按筛选与等级列,最多 500 条,另给符合的总数。 输入法进程里的生词本落盘前会合并文件(VocabularyBook::refresh),App 这边的改动不会被盖掉。搜索同 PC(search:不分大小写,词、带它上屏的中文、例句里有就算),在 Rust 侧筛;搜索框常驻,打字停 250 ms 后只重填下面的词表,不丢焦点。PC 生词本的时间线、导出不做。 - 「看到」次数:候选条每画一帧,
renderStrip把视口里(不算 ⌄ 那一格)看得见的候选与译文交给Engine::note_displayed,横滑后才露出来的不算,同桌面端只算当前一页。 - 输入统计(
Native.statsDetail)的派生数与 PC 设置程序统计页调同一套函数(qingjian_learning::figures,见 crate-notes 的UsageStats),两端「近 7 天」一致;PC 的一年日历改成最近 30 天柱状图,云服务用量不做(用量文件靠文件锁,安卓上不支持)。
高级
PC 设置程序的「高级」页(配置文件、日志、输入日志)在手机上改成「我的 → 高级」,只有导出数据与清空学习数据,不露文件路径;配置文件编辑与输入日志开关不做。
- 导出:
ACTION_CREATE_DOCUMENT(application/zip)让用户选位置,壳用ZipOutputStream把文件平铺写进去;打包哪些由 Rust 的store::learning::export_files定: 学习数据目录顶层的.tsv与.json(输入习惯各表、生词本与时间线、输入统计、常用语、面板常用、个人释义表)。 剪贴板历史可能有密码、验证码,不带(同 PC 导出日志时抹掉密钥);导入的词库与设置也不带。导出前Engine.flushAll让开着的引擎落盘。 - 清空:先勾输入习惯(
FrequencyLearner::files列出的词频与各侧车表)和 / 或生词本(user-vocab.tsv与vocab-log.tsv,全部语言,同 PC 生词本页的「清空」),再确认一次。 输入统计、常用语、剪贴板、词库不动(PC 也没有清它们的入口)。 - App 与输入法同进程,引擎内存里的学习数据下次落盘会把删掉的写回去,所以
Engine.clearLearning先把进程里开着的引擎(Engine伴生对象里登记)落盘卸下,删文件,再重新装配; 装配到一半被卸下的那次结果按generation作废。重装好之前按键原样上屏,onEngineReady重交方案与选项。
外观
「我的 → 外观」对应 PC 设置程序的「外观」页,只搬主题、深浅色与字号;拼音显示位置、渲染引擎、字体、排布、过渡动画是桌面候选窗的事,不做(字体以后有人要再说)。
- 主题:列的是
qingjian-render的全部内置主题(Native.themes),与 PC 同一份,PC 新加主题手机自动跟上;存theme(同 PC 的[general] theme)。卡片上的候选条是渲染器用样例帧画的(appearance::preview,Native.themePreview),下面几排键按该主题的键盘配色画。 预览在 App 进程里画,渲染器不能跨线程,放在thread_local里,壳只在主线程调,画过的按(主题, 深浅, 倍数, 字号档)缓存。 - 键盘配色:
Palette不写颜色,整份由 Rust 推(qingjian_render::KeyboardColors,规则见 design/theme.md「手机键盘」),Native.keyboardPalette交 JSON,壳按(主题, 深浅)缓存。 键盘、面板、浮动编辑条、长按气泡都吃这一份;候选条由会话的画笔按同一主题画(Native.setAppearance,引擎重装后Engine记着重交)。 - 深浅色:
appearance三选一(跟随系统 / 浅色 / 深色,同 PC),跟随系统时看uiMode,系统切换时服务的onConfigurationChanged重建键盘。锁定浅色的主题(四个角色主题)以主题为准,Palette.dark用 Rust 判断后的结果,候选条也按它画。 - 字号:四档(小 0.88 / 标准 / 大 1.15 / 特大 1.3),对应 PC 的候选字号与译文字号,手机上合成一项、按比例调(用户选的)。Rust 侧把主题的
candidate/annotation字号乘上倍数交Theme::with_text_sizes; 候选条在样式上写死的 19 点跟着缩放(渲染器的font_ref改动),译文截断宽度同比放大;条高按max(1, 倍数)放大,展开的候选网格同比缩放。键面上的字不跟(用户选的),另有「键面字号」(key_text_size,同样四档,KeyPainter画字时乘倍数)。 - 震动强度(
haptic_strength):标准是原来的预置 TICK / CLICK,重一档是 CLICK / HEAVY_CLICK;预置效果不能缩放,轻一档用合成振动按 0.5 缩小 tick 原语,不支持时退回小振幅短震。
语音输入
设计见 design/voice-and-swipe.md。现在的识别方是 Rust 侧的火山引擎豆包流式识别(crates/qingjian-voice);「语音识别」页没配好时退回占位的 UnavailableRecognizer,三处入口都在工具栏的位置放一行可点的提示(CandidateBarView.showNotice,5 秒或出候选后收起),点了进设置页;快捷翻译没配云端同样。原来只弹 toast,一闪就过、也没有去设置的路,用户反馈像点了没反应。
- 入口都进
voice/VoiceInput:KeyboardView按住空格 400 ms 不动问onVoiceHold(开着「长按空格说话」时),开不起来退回挪光标;横向滑开一格(14 dp)先到就是挪光标。工具栏话筒与全面屏底栏语音键是点按进。 - 识别方式是
VoiceRecognizer(是否可用、要不要录音权限、开始、说完、取消,回调中间结果、最终结果、出错、音量)。现有 Rust 侧识别方EngineRecognizer:AudioRecord(VOICE_RECOGNITION源)录 16 kHz 单声道,每 100 ms 一截经Native.voiceFeed交 Rust、每 100 mspollVoice取结果;voiceAvailable为真(配好了)时VoiceInput才选它。收尾(说完、出错、取消)一律在stop()里停录音线程,出错时也得停,不然麦克风一直开着。以后可加系统SpeechRecognizer、切到系统语音输入法。 - 中间结果用
setComposingText放进输入框(走Composer.Host.connection,快捷翻译、常用语编辑条开着时进编辑条);最终结果经Session::commit_voice→Engine::commit_voice上屏,替换编辑中的那段。开始前组句先落下(同回车)。 VoicePanel(PanelKind.VOICE)由InputPanel.openVoice盖上,不经openPanel;返回键、换输入框、收起键盘、键盘重建都取消这一段。两块弧是面板下方一个大圆(半径 = 面板宽)顶上离正上方 4°–28° 的两段,按着空格时KeyboardView把手指的屏幕坐标交VoicePanel.track判落在哪块(判定比画出来的宽一些),松手时按它撤回、上屏并执行动作或只上屏。右块写enterLabel(快捷翻译条开着时用它的),执行动作走sendDefaultEditorAction;多行框写「完成」。- 录音权限:
MicrophonePermissionActivity透明窗口代为requestPermissions(自己的任务、不进最近任务,免得把 App 带到前台),结果回调给入口,给了直接开始。拒绝了入口提示没有权限。 - 配置:「我的 → 语音识别」(
VoiceSettingsActivity)与键盘设置里的云端大模型分开,存在voice那份 prefs(VoiceConfig:服务、资源 ID、密钥密文);密钥同样经SecretStore.seal用 Keystore 那把钥匙加密。服务监听这份 prefs,Engine.setVoice交{provider, api_key, resource_id}的 JSON 给Session::set_voice(同一份不重交),「测试连接」走Native.voiceTest(识别半秒静音,阻塞,后台线程调)。
滑行输入
设计见 design/voice-and-swipe.md「滑行」。解码在 Core(ShapeDecoder 按词表打分),壳只判定、记点、画线。Session 按在打什么给候选词:中文是主词库的音节串、英文模式与 decodeSwipe(字母直接上屏)是英文词表,两份都在第一次滑行时建(OnceLock)。
KeyboardView.swipeLayout由服务在showKeyboard里按当前键盘设(字母页,且是拼音全键盘、英文键盘或常用语编辑条的英文键盘);还要设置里swipe_input开着。SwipeTrail管一笔:第一根手指落在Key.Letter上就begin,ACTION_MOVE把历史点一起记上(快划时两帧之间隔得远,不记历史点拐角会被抹平),离起点 0.6 键宽转成滑行,这时撤掉这根手指的按下态(不画气泡)与长按计时。- 抬手时
toJson(slots)拼{points, keys}(像素;键是所有字母键的中心与宽高),Composer.onSwipe:组句或该组句时Native.swipe→Session::swipe(字母逐个engine.push,最后刷一帧),英文直接上屏时Native.decodeSwipe拿字母串上屏,Shift 亮着首字母大写。 - 真机用
adb shell input motionevent DOWN / MOVE / UP模拟(同一条adb shell里串起来,不然每步间隔太长会先触发长按)。
还没做
本机离线语音识别、神经重排、用户主题(PC 主题目录里自己写的主题)。