Android平台并没有为应用开发者提供一套覆盖所有场景的撤销重做框架,很多团队只能围绕EditText自带的有限能力或者自定义命令栈来设计交互。测试这类功能时,如果只盯着正常流程,比如输入一段文字后点击撤销再点击重做,往往无法发现状态管理中的深层缺陷。真正可靠的测试需要覆盖命令栈的边界条件、视图状态的恢复、UI线程与后台任务的协作等多个维度。

一、理解Android中的撤销重做实现方式
在Android应用里,撤销(Undo)和重做(Redo)并不是像桌面端那样在每个文本控件中都默认提供。EditText从API 26开始暴露了部分撤销能力,但接口仍然比较受限,无法直接满足富文本编辑器、画布绘制或多步复合操作的需求。因此,多数应用会选择自己维护一套命令栈,记录每一次可逆操作。
命令模式(Command Pattern)是这里最常用的设计。每个操作被封装为一个命令对象,实现execute和unexecute两个方法;执行时调用execute并将命令压入撤销栈,撤销时从栈中弹出命令并调用unexecute,重做时则将命令再次执行并压入重做栈。当有新操作产生时,重做栈需要清空,这是很多实现容易忽略的细节。下面的代码展示了一个最小化的命令接口和栈管理类。
public interface Command {
void execute();
void unexecute();
}
public class UndoRedoManager {
private Deque<Command> undoStack = new ArrayDeque<>();
private Deque<Command> redoStack = new ArrayDeque<>();
public void execute(Command cmd) {
cmd.execute();
undoStack.push(cmd);
redoStack.clear();
}
public void undo() {
if (!undoStack.isEmpty()) {
Command cmd = undoStack.pop();
cmd.unexecute();
redoStack.push(cmd);
}
}
public void redo() {
if (!redoStack.isEmpty()) {
Command cmd = redoStack.pop();
cmd.execute();
undoStack.push(cmd);
}
}
public boolean canUndo() {
return !undoStack.isEmpty();
}
public boolean canRedo() {
return !redoStack.isEmpty();
}
}
二、单元测试撤销重做逻辑:从命令栈开始
针对上面这层纯粹的业务逻辑,我们完全可以在JVM上直接编写JUnit测试,不需要启动模拟器或Robolectric。这样测试执行速度快,反馈也直接。测试的重点应该放在命令栈的状态转移是否符合预期,而不仅仅是最终文本内容是否正确。
一个典型的测试用例可以这样设计:先执行两个命令,验证撤销栈和重做栈的深度;执行一次撤销,验证内容回到上一步且重做栈增加;再执行新命令,验证重做栈被清空。除此之外,空栈直接调用undo或redo也不应该抛出异常,这属于边界测试。下面给出一个测试类骨架。
import org.junit.Test;
import static org.junit.Assert.*;
public class UndoRedoManagerTest {
private StringBuilder model = new StringBuilder();
private Command appendCommand(final String text) {
return new Command() {
@Override public void execute() {
model.append(text);
}
@Override public void unexecute() {
model.delete(model.length() - text.length(), model.length());
}
};
}
@Test
public void testUndoRedoNewCommandClearsRedo() {
UndoRedoManager manager = new UndoRedoManager();
manager.execute(appendCommand("Hello"));
manager.execute(appendCommand(" World"));
assertEquals("Hello World", model.toString());
manager.undo();
assertEquals("Hello", model.toString());
assertTrue(manager.canRedo());
manager.execute(appendCommand(" Android"));
assertEquals("Hello Android", model.toString());
assertFalse(manager.canRedo());
}
@Test
public void testEmptyStackUndoRedoDoesNotThrow() {
UndoRedoManager manager = new UndoRedoManager();
manager.undo();
manager.redo();
assertFalse(manager.canUndo());
assertFalse(manager.canRedo());
}
}
三、用Robolectric测试EditText的撤销重做
上面测试的是自己实现的命令栈,但很多应用直接依赖EditText的文本变化。这时可以使用Robolectric在JVM上运行Android相关代码,验证文本控件的撤销重做接口。Robolectric能够模拟TextView和EditText的大部分行为,让测试保持较快速度的同时覆盖真实控件调用。
需要注意,EditText的撤销能力在不同Android版本上表现并不一致,有些厂商ROM甚至会修改内部实现。因此用Robolectric固定一个标准SDK版本进行测试,可以排除设备碎片带来的干扰。下面的测试代码演示如何获取EditText中的UndoManager并断言撤销操作后的文本内容。
import android.widget.EditText;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import static org.junit.Assert.*;
@RunWith(RobolectricTestRunner.class)
public class EditTextUndoTest {
@Test
public void testEditTextUndoRedoText() {
EditText editText = new EditText(RuntimeEnvironment.getApplication());
editText.setText("abc");
editText.append("def");
assertEquals("abcdef", editText.getText().toString());
editText.getUndoManager().undo();
assertEquals("abc", editText.getText().toString());
editText.getUndoManager().redo();
assertEquals("abcdef", editText.getText().toString());
}
}
四、Espresso驱动的UI撤销重做测试
单元测试证明了逻辑正确,但最终用户体验还涉及按钮点击、输入法交互和视图刷新。Espresso可以用来在真机或模拟器上驱动UI,模拟用户点击撤销按钮并检查EditText中的文本。这类测试通常需要依赖视图ID,测试代码运行在Android测试环境中。
编写Espresso测试时,建议将撤销按钮和重做按钮分别赋予独立的资源ID,避免使用文字查找带来的脆弱性。测试流程可以包括输入文本、点击按钮、断言文本,甚至可以验证按钮的启用状态是否随着栈深度变化而正确切换。下面是一个典型的测试方法。
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.rule.ActivityTestRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.typeText;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
@RunWith(AndroidJUnit4.class)
public class EditorActivityTest {
@Rule
public ActivityTestRule<EditorActivity> activityRule =
new ActivityTestRule<>(EditorActivity.class);
@Test
public void testUndoRedoButtonFlow() {
onView(withId(R.id.edit_text)).perform(typeText("hello"));
onView(withId(R.id.btn_undo)).perform(click());
onView(withId(R.id.edit_text)).check(matches(withText("hell")));
onView(withId(R.id.btn_redo)).perform(click());
onView(withId(R.id.edit_text)).check(matches(withText("hello")));
}
}
五、容易忽视的测试盲区与优化建议
上述测试覆盖了常规路径,但真实场景里还有很多容易遗漏的状态。例如Activity被系统回收后,用户之前积累的撤销栈是否得到保存和恢复?如果只是把UndoRedoManager放在Activity字段里而没有处理onSaveInstanceState,旋转屏幕后用户点击撤销可能什么都不发生甚至崩溃。测试时可以用ActivityScenario模拟重建,并断言撤销栈深度不变。
另一个常见问题是输入法组合文本。中文、日文等输入法在组字过程中会产生中间状态,如果直接将这些中间状态压入撤销栈,用户一次确认输入可能会拆成多次撤销。测试时应该模拟完整的组字流程,确认只有最终提交的文本才被记录为一个命令。另外,异步任务回调中修改文本也可能绕过命令栈,导致撤销顺序错乱,这类场景需要借助CountDownLatch或IdlingResource来同步测试。
性能方面,如果命令对象持有Bitmap或大段字符串的引用,长时间使用后撤销栈会占用大量内存。测试中可以通过Android Profiler或Heap Dump验证内存增长,但这已经超出自动化断言的范围。更实际的做法是在命令中保存轻量级的数据差异,而不是完整副本,并在测试中验证内存占用保持稳定。总之,撤销重做的可靠测试既要关注逻辑正确性,也要覆盖状态恢复、输入法组合和异步修改这些边界。
Android撤销重做Undo Redo测试自动化测试修改时间:2026-09-26 11:29:50