perf(输入法): 降低查词与候选内存开销并放大等尺寸26键

这个提交包含在:
sydxky 于 2026-11-14 20:39:54 +08:00 提交
1 父节点 100757f684
当前提交 7cd1ab906c
33 个文件已更改
+513 -126

没有与搜索条件相匹配的文件

@@ -1,10 +1,13 @@
package com.sydxky.crabinput;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.os.SystemClock;
import android.os.CancellationSignal;
import android.os.OperationCanceledException;
import android.text.Editable;
import android.text.InputType;
import android.view.ContextThemeWrapper;
@@ -22,6 +25,7 @@ import static org.junit.Assert.*;
/** Native offscreen regression: no Activity, foreground switch or system IME. */
public class SurfaceRegressionTest {
private Context context(){return new ContextThemeWrapper(InstrumentationRegistry.getInstrumentation().getTargetContext(),android.R.style.Theme_Material_Light_NoActionBar);}
private Context context(boolean round){Context base=context();Configuration configuration=new Configuration(base.getResources().getConfiguration());configuration.screenLayout=(configuration.screenLayout&~Configuration.SCREENLAYOUT_ROUND_MASK)|(round?Configuration.SCREENLAYOUT_ROUND_YES:Configuration.SCREENLAYOUT_ROUND_NO);return new ContextThemeWrapper(base.createConfigurationContext(configuration),android.R.style.Theme_Material_Light_NoActionBar);}
private void ui(Runnable action){InstrumentationRegistry.getInstrumentation().runOnMainSync(action);}
private View find(View root,String description){
if(description.contentEquals(root.getContentDescription()==null?"":root.getContentDescription()))return root;
@@ -98,4 +102,41 @@ public class SurfaceRegressionTest {
}finally{controller.close();}
});
}
@Test public void enlargedAlphabetKeysHaveEqualSizeOnRoundAndRectangularScreens(){
ui(()->{
for(boolean round:new boolean[]{false,true})for(int[] size:round?new int[][]{{466,466},{192,192}}:new int[][]{{466,466},{390,450},{192,192}})for(String mode:new String[]{"py","en"}){
Context themed=context(round);KeyboardState state=new KeyboardState();state.mode=mode;
KeyboardSurface surface=new KeyboardSurface(themed,state,new KeyboardSurface.Actions(){public void action(String v){}public void choose(Candidate v){}public void repeatDelete(boolean v){}});
int width=size[0],height=size[1];surface.measure(View.MeasureSpec.makeMeasureSpec(width,View.MeasureSpec.EXACTLY),View.MeasureSpec.makeMeasureSpec(height,View.MeasureSpec.EXACTLY));surface.layout(0,0,width,height);
int minW=Integer.MAX_VALUE,maxW=0,minH=Integer.MAX_VALUE,maxH=0;
for(char letter:"qwertyuiopasdfghjklzxcvbnm".toCharArray()){
View key=find(surface,String.valueOf(letter));assertNotNull(key);minW=Math.min(minW,key.getWidth());maxW=Math.max(maxW,key.getWidth());minH=Math.min(minH,key.getHeight());maxH=Math.max(maxH,key.getHeight());
}
assertTrue("All 26 letters have the same width within pixel rounding",maxW-minW<=1);assertTrue("All 26 letters have the same height within pixel rounding",maxH-minH<=1);assertTrue(minW>width*.07);assertTrue(minH>height*.13);
for(int i=0;i<surface.getChildCount();i++){
View child=surface.getChildAt(i);assertTrue("No child overflows",child.getWidth()>0&&child.getHeight()>0&&child.getLeft()>=0&&child.getTop()>=0&&child.getRight()<=width&&child.getBottom()<=height);
if(round&&child instanceof android.widget.Button)for(int x:new int[]{child.getLeft(),child.getRight()})for(int y:new int[]{child.getTop(),child.getBottom()})assertTrue("Round buttons stay inside safe screen",Math.hypot(x-width/2.0,y-height/2.0)<=width/2.0-1);
}
}
});
}
@Test public void sameSizeEditsDoNotRebuildTheKeyboard(){
ui(()->{
KeyboardState state=new KeyboardState();state.mode="py";state.candidates=Arrays.asList(new Candidate("你好",5));
KeyboardSurface surface=new KeyboardSurface(context(),state,new KeyboardSurface.Actions(){public void action(String v){}public void choose(Candidate v){}public void repeatDelete(boolean v){}});
for(int frame=0;frame<24;frame++){
state.preview="输入"+frame;state.composition=frame%2==0?"niha":"nihao";state.busy=frame%2==0;state.candidates=Arrays.asList(new Candidate(frame%2==0?"你好":"您好",5));
surface.forceLayout();surface.measure(View.MeasureSpec.makeMeasureSpec(466,View.MeasureSpec.EXACTLY),View.MeasureSpec.makeMeasureSpec(466,View.MeasureSpec.EXACTLY));surface.layout(0,0,466,466);
assertEquals("Text-only updates reuse placements and key layout",1,surface.layoutBuilds);assertEquals("输入"+frame,((TextView)find(surface,"编辑光标")).getText().toString());assertNotNull(find(surface,frame%2==0?"你好":"您好"));
}
});
}
@Test public void cancelledDictionaryQueryDoesNotBreakFollowingInput()throws Exception{
try(Lexicon lexicon=new Lexicon(context())){
CancellationSignal signal=new CancellationSignal();signal.cancel();
try{lexicon.lookup("nihao","py","",signal);fail("Cancelled lookup must stop");}catch(OperationCanceledException expected){}
try{lexicon.predict("你好",signal);fail("Cancelled continuation must stop");}catch(OperationCanceledException expected){}
assertEquals("你好",lexicon.lookup("niha","py").get(0).word);assertEquals("世界",lexicon.predict("你好").get(0).word);
}
}
}
@@ -2,6 +2,8 @@ package com.sydxky.crabinput;
import java.util.ArrayList;
import java.util.List;
import java.util.AbstractList;
import java.util.RandomAccess;
/** 4 columns x 4 row units; long phrases get full width and extra row units. */
public final class CandidatePager {
@@ -16,7 +18,7 @@ public final class CandidatePager {
public int slots;
Row(int units) { this.units=units; }
}
public static List<List<Row>> pages(List<Candidate> candidates) {
private static List<List<Row>> materialize(List<Candidate> candidates) {
List<List<Row>> pages=new ArrayList<>();List<Row> page=new ArrayList<>();
Row row=null;int used=0;
for(Candidate candidate:candidates) {
@@ -33,5 +35,27 @@ public final class CandidatePager {
if(!page.isEmpty())pages.add(page);
return pages;
}
private static final class Pages extends AbstractList<List<Row>> implements RandomAccess {
private final List<Candidate> candidates;
private final List<Integer> starts=new ArrayList<>();
Pages(List<Candidate> candidates){
this.candidates=candidates;int used=0,slots=0,rowUnits=0;
for(int i=0;i<candidates.size();i++){
String word=candidates.get(i).word;int length=word.codePointCount(0,word.length());
int span=length==1?1:length<=4?2:4,units=span==4?Math.max(1,(length+11)/12):1;
if(slots>0&&(slots+span>4||rowUnits!=units)){used+=rowUnits;slots=0;}
if(starts.isEmpty()||used+units>4){starts.add(i);used=0;slots=0;}
if(slots==0)rowUnits=units;
slots+=span;if(slots==4){used+=rowUnits;slots=0;}
}
}
@Override public int size(){return starts.size();}
@Override public List<Row> get(int page){
if(page<0||page>=size())throw new IndexOutOfBoundsException();
int end=page+1<size()?starts.get(page+1):candidates.size();
return materialize(candidates.subList(starts.get(page),end)).get(0);
}
}
public static List<List<Row>> pages(List<Candidate> candidates){return new Pages(candidates);}
private CandidatePager() {}
}
@@ -12,6 +12,7 @@ import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import android.os.CancellationSignal;
/** One transient in-app editing session. It never selects a system IME. */
final class KeyboardController implements KeyboardSurface.Actions,AutoCloseable {
@@ -25,6 +26,7 @@ final class KeyboardController implements KeyboardSurface.Actions,AutoCloseable
private final Runnable dismiss;
private final Context context;
private Future<?> pending;
private CancellationSignal cancellation;
private int revision;
private boolean active=true,repeat,closed;
private final Runnable lookup=()->query(revision,state.composition,state.mode,contextText());
@@ -37,7 +39,7 @@ final class KeyboardController implements KeyboardSurface.Actions,AutoCloseable
state.mode=kind==InputType.TYPE_CLASS_NUMBER||kind==InputType.TYPE_CLASS_PHONE?"num":state.secret||variation==InputType.TYPE_TEXT_VARIATION_URI||variation==InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS?"en":"t9";
surface=new KeyboardSurface(context,state,this);refresh();
}
private void invalidateLookup(){revision++;main.removeCallbacks(lookup);if(pending!=null){pending.cancel(true);pending=null;}state.busy=false;}
private void invalidateLookup(){revision++;main.removeCallbacks(lookup);if(cancellation!=null){cancellation.cancel();cancellation=null;}if(pending!=null){pending.cancel(true);pending=null;}state.busy=false;}
private void refresh(){
if(active&&!state.secret){CharSequence text=input.getTextBeforeCursor(24,0);state.preview=text==null?"":text.toString();}else state.preview="";
if(!repeat)surface.refresh();
@@ -54,8 +56,9 @@ final class KeyboardController implements KeyboardSurface.Actions,AutoCloseable
state.prediction=prediction;state.busy=true;main.postDelayed(lookup,40);refresh();
}
private void query(int owner,String composition,String mode,String context){
CancellationSignal signal=new CancellationSignal();cancellation=signal;
pending=worker.submit(()->{
try {List<Candidate> result=composition.isEmpty()?lexicon.predict(context):lexicon.lookup(composition,mode,context);main.post(()->{if(!active||revision!=owner)return;state.candidates=result;state.pages=CandidatePager.pages(result);state.page=0;state.busy=false;state.message=result.isEmpty()&&!composition.isEmpty()?"暂无匹配,请退格修改":state.prediction?"联想":"离线输入";refresh();});}
try {List<Candidate> result=composition.isEmpty()?lexicon.predict(context,signal):lexicon.lookup(composition,mode,context,signal);List<List<CandidatePager.Row>> pages=CandidatePager.pages(result);main.post(()->{if(!active||revision!=owner)return;state.candidates=result;state.pages=pages;state.page=0;state.busy=false;state.message=result.isEmpty()&&!composition.isEmpty()?"暂无匹配,请退格修改":state.prediction?"联想":"离线输入";refresh();});}
catch(Exception ignored){main.post(()->{if(active&&revision==owner){clearCandidates();state.busy=false;state.message="词库暂不可用,可切换英文";refresh();}});}
});
}
@@ -34,7 +34,11 @@ final class KeyboardSurface extends ViewGroup {
private final Set<String> used=new HashSet<>();
private boolean refreshPosted;
private int builtWidth,builtHeight;
private final Runnable render=()->{refreshPosted=false;if(builtWidth>0)build(builtWidth,builtHeight);requestLayout();invalidate();};
private String builtKey="";
private List<Candidate> builtCandidates;
int layoutBuilds;
private final Runnable render=this::renderFrame;
private void renderFrame(){refreshPosted=false;if(builtWidth>0){if(!state.expanded&&!state.tools&&layoutKey().equals(builtKey))updateContent();else{build(builtWidth,builtHeight);requestLayout();}}else requestLayout();invalidate();}
private float sx=1,sy=1;
private static final class Placement {
final View view;final Rect bounds;
@@ -95,10 +99,28 @@ final class KeyboardSurface extends ViewGroup {
float inset=inset(y,height),width=410-2*inset,cell=width/captions.length;
for(int i=0;i<captions.length;i++)action(captions[i],values[i],inset+i*cell+2,y,cell-4,height,font,false);
}
private String layoutKey(){return state.mode+":"+state.upper+":"+state.symbolPage+":"+state.tools+":"+state.expanded+":"+state.page+":"+Math.min(3,state.candidates.size());}
private void text(String key,String value){TextView view=(TextView)children.get(key);if(view!=null&&!view.getText().toString().equals(value))view.setText(value);}
private String compositionText(){
if(state.composition.isEmpty())return modeName();
if(!state.mode.equals("14"))return state.composition;
StringBuilder result=new StringBuilder();for(char ch:state.composition.substring(Math.max(0,state.composition.length()-6)).toCharArray())result.append(Lexicon.GROUP14["0123456789abcd".indexOf(ch)].toUpperCase(Locale.ROOT)).append(' ');return result.toString();
}
private void updateContent(){
text("preview",state.secret?"私密输入":state.preview.isEmpty()?"开始输入…":state.preview);text("composition",compositionText());
text("candidate-status",state.busy?"正在查词…":state.message);
for(int i=0;i<Math.min(3,state.candidates.size());i++){
Candidate candidate=state.candidates.get(i);Button button=(Button)children.get("candidate:"+i);if(button==null)continue;
if(!button.getText().toString().equals(candidate.word))button.setText(candidate.word);
button.setContentDescription(candidate.word);button.setEnabled(!state.busy);
if(builtCandidates!=state.candidates)button.setOnClickListener(v->actions.choose(candidate));
}
builtCandidates=state.candidates;
}
private void build(int width,int height) {
builtWidth=width;builtHeight=height;sx=width/410f;sy=height/410f;placements.clear();used.clear();
builtWidth=width;builtHeight=height;builtKey=layoutKey();builtCandidates=state.candidates;layoutBuilds++;sx=width/410f;sy=height/410f;placements.clear();used.clear();
try{
float top=round?28:7;
float top=round?20:7;
float headerInset=inset(top,23);
action("⌄","hide",headerInset,top,32,23,23,false);
String title=state.expanded?"完整候选":state.tools?"编辑工具":"蟹蟹输入";
@@ -113,9 +135,7 @@ final class KeyboardSurface extends ViewGroup {
if(state.expanded) { buildCandidates(previewY+38);return; }
if(state.tools) { buildTools(previewY+40);return; }
float compositionY=previewY+32;
String composition=state.composition;
if(state.mode.equals("14")) { StringBuilder display=new StringBuilder();for(char ch:composition.substring(Math.max(0,composition.length()-6)).toCharArray())display.append(Lexicon.GROUP14["0123456789abcd".indexOf(ch)].toUpperCase(Locale.ROOT)).append(' ');composition=display.toString(); }
TextView compose=label("composition",composition.isEmpty()?modeName():composition,15,BLUE);compose.setMaxLines(1);
TextView compose=label("composition",compositionText(),15,BLUE);compose.setMaxLines(1);
float ci=inset(compositionY,23);place(compose,ci,compositionY,410-2*ci,23);
float candidateY=compositionY+26;
if(isChinese()) {
@@ -138,9 +158,17 @@ final class KeyboardSurface extends ViewGroup {
}
float keyHeight=captions.length==4?36:49;
// Hide an unused suggestion strip in direct entry modes to enlarge the keys.
if(!isChinese()){keyHeight=captions.length==4?43:66;}
else if(state.mode.equals("py")){keyHeight=55;}
for(int i=0;i<captions.length;i++)row(captions[i],values[i],keyY+i*(keyHeight+4),keyHeight,captions[i].length>=7?21:24);
if(!isChinese()){keyHeight=captions.length==4?43:round?68:77;}
else if(state.mode.equals("py")){keyHeight=round?56:61;}
boolean qwerty=state.mode.equals("py")||state.mode.equals("en");
float common=Float.MAX_VALUE;
if(qwerty)for(int i=0;i<captions.length;i++)common=Math.min(common,(410-2*inset(keyY+i*(keyHeight+4),keyHeight))/captions[i].length);
for(int i=0;i<captions.length;i++){
float y=keyY+i*(keyHeight+4);
if(!qwerty){row(captions[i],values[i],y,keyHeight,captions[i].length>=7?21:24);continue;}
float left=(410-common*captions[i].length)/2;
for(int j=0;j<captions[i].length;j++)action(captions[i][j],values[i][j],left+j*common+1,y,common-2,keyHeight,21,false);
}
float modeY=keyY+captions.length*(keyHeight+4)+3;
row(new String[]{"九宫","26键","14键",state.mode.equals("en")?(state.upper?"小写":"大写"):"abc","123","符号"},new String[]{"mode:t9","mode:py","mode:14","mode:en","mode:num","mode:sym"},modeY,27,13);
bottom(modeY+34,false);
@@ -186,7 +214,7 @@ final class KeyboardSurface extends ViewGroup {
private static String[][] values(String[][] captions){String[][] result=new String[captions.length][];for(int i=0;i<captions.length;i++){result[i]=new String[captions[i].length];for(int j=0;j<result[i].length;j++)result[i][j]="key:"+captions[i][j];}return result;}
@Override protected void onMeasure(int widthSpec,int heightSpec) {
int width=MeasureSpec.getSize(widthSpec),height=resolveSize(desiredHeight(),heightSpec);
if(width!=builtWidth||height!=builtHeight)build(width,height);
if(width!=builtWidth||height!=builtHeight||!layoutKey().equals(builtKey)||state.expanded&&builtCandidates!=state.candidates)build(width,height);else if(!state.expanded&&!state.tools)updateContent();
for(Placement item:placements)item.view.measure(MeasureSpec.makeMeasureSpec(item.bounds.width(),MeasureSpec.EXACTLY),MeasureSpec.makeMeasureSpec(item.bounds.height(),MeasureSpec.EXACTLY));
setMeasuredDimension(width,height);
}
@@ -10,6 +10,8 @@ import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import android.os.CancellationSignal;
import java.util.regex.Pattern;
/** Full upstream dictionary, offline. Called only on the service's single worker. */
public final class Lexicon implements AutoCloseable {
@@ -17,6 +19,7 @@ public final class Lexicon implements AutoCloseable {
public static final String[] GROUP9={"abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"};
private final Context context;
private SQLiteDatabase db;
private static final Pattern CONTEXT=Pattern.compile("[\\u3400-\\u9fff]{1,4}$");
public Lexicon(Context context) { this.context=context.getApplicationContext(); }
public static String encode(String text,String mode) {
String[] groups=mode.equals("14")?GROUP14:GROUP9;String tokens=mode.equals("14")?"0123456789abcd":"23456789";
@@ -43,18 +46,26 @@ public final class Lexicon implements AutoCloseable {
return lookup(input,mode,"");
}
public List<Candidate> predict(String context) throws Exception {
return predict(context,null);
}
public List<Candidate> predict(String context,CancellationSignal signal) throws Exception {
if(signal!=null)signal.throwIfCanceled();
List<Candidate> result=new ArrayList<>();
java.util.regex.Matcher match=java.util.regex.Pattern.compile("[\\u3400-\\u9fff]{1,4}$").matcher(context==null?"":context);
java.util.regex.Matcher match=CONTEXT.matcher(context==null?"":context);
if(!match.find())return result;
open();String tail=match.group();Set<String> seen=new HashSet<>();
for(int length=tail.length();length>0&&result.size()<8;length--){
try(Cursor cursor=db.rawQuery("SELECT word,frequency FROM associations WHERE prefix=? ORDER BY frequency DESC,word LIMIT 8",new String[]{tail.substring(tail.length()-length)})){
try(Cursor cursor=db.rawQuery("SELECT word,frequency FROM associations WHERE prefix=? ORDER BY frequency DESC,word LIMIT 8",new String[]{tail.substring(tail.length()-length)},signal)){
while(cursor.moveToNext()&&result.size()<8){String word=cursor.getString(0);if(seen.add(word))result.add(new Candidate(word,0,cursor.getInt(1),false));}
}
}
return result;
}
public List<Candidate> lookup(String input,String mode,String context) throws Exception {
return lookup(input,mode,context,null);
}
public List<Candidate> lookup(String input,String mode,String context,CancellationSignal signal) throws Exception {
if(signal!=null)signal.throwIfCanceled();
List<Candidate> result=new ArrayList<>();
String pattern=mode.equals("14")?"[0-9a-d]{1,32}":mode.equals("t9")?"[2-9]{1,32}":"[a-z]{1,32}";
if(!input.matches(pattern))return result;open();
@@ -63,7 +74,7 @@ public final class Lexicon implements AutoCloseable {
for(int i=0;i<input.length();i++){if(i>0)marks.append(',');marks.append('?');prefixes[i]=input.substring(0,i+1);}
String order=mode.equals("py")?"length(e.pinyin) DESC,e.pinyin,w.ordinal":"length(e.pinyin) DESC,w.frequency DESC,e.pinyin,w.ordinal";
Set<String> seen=new HashSet<>();
try(Cursor cursor=db.rawQuery("SELECT w.word,length(e.pinyin),w.frequency FROM entries e JOIN words w ON w.entry=e.id WHERE e."+column+" IN ("+marks+") ORDER BY "+order,prefixes)) {
try(Cursor cursor=db.rawQuery("SELECT w.word,length(e.pinyin),w.frequency FROM entries e JOIN words w ON w.entry=e.id WHERE e."+column+" IN ("+marks+") ORDER BY "+order,prefixes,signal)) {
while(cursor.moveToNext()) {
if(Thread.currentThread().isInterrupted())return new ArrayList<>();
String word=cursor.getString(0);int consume=cursor.getInt(1);
@@ -72,7 +83,7 @@ public final class Lexicon implements AutoCloseable {
}
if(input.length()>=2){
String sql="SELECT w.word,w.frequency FROM entries e JOIN words w ON w.entry=e.id WHERE e."+column+">? AND e."+column+"<? AND length(e.pinyin)<=? ORDER BY w.frequency DESC,length(e.pinyin),e.pinyin,w.ordinal LIMIT 128";
try(Cursor cursor=db.rawQuery(sql,new String[]{input,input+'\uffff',Integer.toString(input.length()+4)})){
try(Cursor cursor=db.rawQuery(sql,new String[]{input,input+'\uffff',Integer.toString(input.length()+4)},signal)){
int added=0;
while(cursor.moveToNext()&&added<12){
if(Thread.currentThread().isInterrupted())return new ArrayList<>();
@@ -81,7 +92,7 @@ public final class Lexicon implements AutoCloseable {
}
}
}
CandidateRanking.sort(result,input.length(),predict(context));
CandidateRanking.sort(result,input.length(),predict(context,signal));
return result;
}
@Override public void close() { if(db!=null){db.close();db=null;} }
@@ -20,6 +20,13 @@ public class CandidatePagerTest {
for(List<CandidatePager.Row> page:pages)assertTrue(page.stream().mapToInt(row->row.units).sum()<=4);
}
@Test public void emptyListHasNoPhantomButtons(){assertTrue(CandidatePager.pages(new ArrayList<>()).isEmpty());}
@Test public void largeDictionaryMaterializesOnlyTheRequestedPage(){
List<Candidate> input=new ArrayList<>();for(int i=0;i<12000;i++)input.add(new Candidate(i%13==0?"完整词组":String.valueOf((char)(0x4E00+i%1200)),3));
List<List<CandidatePager.Row>> pages=CandidatePager.pages(input);List<Candidate> restored=new ArrayList<>();
assertTrue(pages.size()>700);assertNotSame("Pages are created on demand",pages.get(0),pages.get(0));
for(List<CandidatePager.Row> page:pages){int cells=0,units=0;for(CandidatePager.Row row:page){units+=row.units;for(CandidatePager.Cell cell:row.cells){restored.add(cell.candidate);cells++;}}assertTrue(cells<=16);assertTrue(units<=4);}
assertEquals("No candidate is omitted or reordered",input,restored);
}
@Test public void pairedAndT9MappingsCoverExpectedWords(){
assertEquals("c3754",Lexicon.encode("nihao","14"));assertEquals("64426",Lexicon.encode("nihao","t9"));assertEquals("",Lexicon.encode("a!","14"));
}