作者:Michael 文章来源:SUN中国技术社区
下载 项目源代码
在本系列的第一篇文章中,我们构建了一个简单的可在任何支持 TCP/IP 插槽的 MIDP 设备上运行的终端模拟器。它包含一个实现 Telnet 协议的 Connection
和一个经过定制来显示终端内容的 Canvas
。在第二篇文章中,我假定您已经阅读了第一篇文章并熟悉了这些组件。
现在我们将进一步加强这个应用程序。首先,我们将通过添加一个更高级的终端类型来增加一些复杂性。然后我们将添加对用户输入的支持——注意大多数移动设备的各种限制。完成后,我们就能够使用这个应用程序来通过 Telnet 连接到远程服务器并运行许多种类的程序,而这一切都是从您的 MIDP 设备来进行的。
关于 ANSI 终端
在上一篇文章中我们实现了一个“哑巴”终端。它显示的仅仅是一个字符流,当进入的字符到达屏幕的边缘时就换行到下一行,在遇到一个换行符时就跳到下一行。虽然这种程度的交互性对于整代的命令行应用程序是足够的,但较复杂的软件则将屏幕作为一个整体来处理,这就需要编写和清除特定位置的字符以提供更好的用户体验。
这里有一段有趣的历史。在 20 世纪 70 年代,众多生产视频终端的厂商提供的屏幕操纵功能专有且各不相同,并以不兼容的方式来实现。要编写可利用多种这样的设备的面向屏幕的软件就很困难。
American National Standards Institute (ANSI) 参与进来。坚持其通过可互操作性来支持商业的要求,ANSI 介入并发布了标准 X3.64:Additional Controls for Use with American National Standard Code for Information Interchange。这个文件定义了现在所知的 ANSI 终端类型。它对将光标移动到屏幕上的特定位置、在光标位置插入和删除字符都规定了标准的命令序列。
最为重要的是命令序列定义自身,因为即使是未实现所有命令的终端也至少能够将不支持的命令识别为命令,并安全地忽视它们。这一进步使得软件开发商可以按照通用标准来编写,并确信他们的应用程序能够至少占用较小的容量来在很多厂商的设备上运行。ANSI 的故事是软件行业一个重复出现的主题的一个很好的例子:采用一种标准会极大地扩展可互操作性。
ANSI 终端标准是一种很像 Telnet 协议的协议。它定义了多个特殊的字符序列,使一个应用程序可区分要解释的命令和要显示到屏幕的数据。
ANSI 终端是我们将要模拟的终端类型。TelnetConnection
已经在其到达屏幕之前过滤出 Telnet 握手和协商;现在我们将增加另一个过滤器来过滤出并解释 ANSI 命令。因为这些命令是我们的终端的指令,实现该逻辑的最好的地方就是在 TelnetCanvas
类自身中。
终端转义序列如何工作?
当 Telnet 使用字节值 255 来以信号方式表示一个命令序列时,ANSI 使用 ASCII 转义序列,其值是 27。ANSI 将如下工作:
我们从输入读取一个字节。如果其值不是 27 (
ESC
),则它不是一个命令∶我们将它直接送到应用程序并继续读取。我们读取下一个字节。如果其值不是 133 (
[
),则它不是一个命令;我们将该字节后所跟的 27 直接送到应用程序并继续读取。我们将继续读取,直到读取到大于 63 的一个字节。这些字节形成一个字符串,其中含有命令的参数。最后一个字节(是 64 或更大)是命令代码。我们处理(或忽略)该命令并继续读取。
许多命令从官方来讲是标准的一部分。虽然完全模拟是一个值得的目标,我们仍将精力放在获得足够的功能来使众多软件可以接受地运行。我们将实现以下命令:
Cursor Control Sequences Erase Sequences A
Move cursor up n lines@
Insert n blank spacesB
Move cursor down n linesJ
Erase display: after cursor (n=0), before cursor (n=1), or entirely (n=2).C
Move cursor forward n spacesD
Move cursor backward n spacesK
Erase line: after cursor (n=0), before cursor (n=1), or entirely (n=2).G
Move cursor to column xH
Move cursor to column x, row yL
Insert n new blank linesd
Move cursor to row yM
Delete n lines from cursors
Save current cursor positionP
Delete n characters from cursoru
Return to saved cursor position
大多数这些命令期望参数采用分号分割字符串的形式。参数出现在序列 ESC [
后和命令字节前。例如,一条用于将光标移动到第 10 行和第 10 列的命令将如下所示:ESC [ 1 0 ; 1 0 H。
许多命令只带一个参数;例如,ESC [ 2 J
将清除屏幕显示。漏掉的参数将默认为 1,所以 ESC [ H
将把光标返回到坐标 (1,1),而 ESC [ B
则将光标移动到下一行。
虽让有很多的东西要处理,但协议却相当简单。
增强 Telnet Canvas 类
我们需要在两个领域中更新 TelnetCanvas。
它需要解释从远程主机接收的 ANSI 命令并发送 ANSI 命令来响应用户输入。对于输入,我们将允许用户使用设备上的键盘来移动光标。
实现 ANSI
虽然我们必须修改 TelnetCanvas
类的内部,但却没有必要更改公共接口。所有数据将仍由 receive()
方法来接收。我们只更改其实现来监视转义序列:
/** * Appends the specified ascii byte to the output. */ public void receive( byte b ) { // ignore nulls if ( b == 0 ) return; if ( state == PARAMS_STATE ) { // if still receiving parameters if ( b < 64 ) { argbuf[0]++; // grow if needed if ( argbuf[0] == argbuf.length ) { char[] tmp = new char[ argbuf.length * 2 ]; System.arraycopy( argbuf, 0, tmp, 0, argbuf.length ); argbuf = tmp; } argbuf[ argbuf[0] ] = (char) b; } else // final byte: process the command { processCommand( b ); // reset for next command argbuf[0] = 0; state = NORMAL_STATE; } } else if ( state == ESCAPE_STATE ) { // if a valid escape sequence if ( b == '[' ) { state = PARAMS_STATE; } else // not an escape sequence { // allow escape to pass through state = NORMAL_STATE; processData( (byte) 27 ); processData( b ); } } else // NORMAL_STATE { if ( b == 27 ) { state = ESCAPE_STATE; } else { processData( b ); } } } |
该方法实现了一个具有三个状态的简单状态机。NORMAL_STATE
监视任何 ESC
字节并将其他任何东西发送到 processData()。
当一个 ESC
字节来到时,ESCAPE_STATE
会接管并检查下一个字节是否是 133 ([
)。如果是,我们就转到 PARAMS_STATE
,同时累加参数字符串直到我们碰到此命令字符。我们在执行该操作时,调用 processCommand()
,然后转回 NORMAL_STATE
。
读取命令参数的代码值得进行检查。为了避免创建和保存 StringBuffer
所带来的系统开销,我们使用一个称为 argbuf
的字符数组。为了避免不断的内存重新分配,我们使其比所需的大一些并保持富余,同时根据需要扩大它。最后,为了跟踪下一个字符到达何处,我们借用 Pascal 的一个技巧,将参数字符串的长度存储在数组的第一个元素中。getArgument()
和 getArgumentCount()
参数处理来自该数组的单个参数的分析和提取。
我们现在将曾经位于 receive()
方法中的代码转到 processData()
方法。逻辑是一样的,将进入的字节放在当前的光标位置,除了接近方法主体末尾的这两行外,代码没有变化:
/** * Appends the specified byte to the display buffer. */ protected void processData( byte b ) { ... // increment bound if necessary while ( cursor > bound ) bound += columns; ... } |
采用较早版本的 MIDTerm 的简单的面向流的方法时,光标不仅标出了进入数据的插入点,同时标出了应该在屏幕上显示的数据缓冲区的外界。由于光标现在可以向上和向前移动到数据缓冲区,就需要一个额外的变量来跟踪数据的外界,以便我们可以确定屏幕的底部在什么位置。这个变量称为 bound
,必须跟踪光标并在光标移动时位于它前面。
虽然 processData()
处理了大部分字节,但在接收到合法的终端命令时还是要调用 processCommand()
。这个方法是我们的 ANSI 实现的核心。
/** * Executes the specified ANSI command, obtaining arguments * as needed from the getArgument() and getArgumentCount() * methods. */ protected void processCommand( byte command ) { try { switch ( command ) { ... // other commands go here case 'd': // cursor to row x if ( argbuf[0] > 0 ) { cursor = bound - ((rows-getArgument( 0 )+1)*columns) + ( cursor % columns ); } break; case 'G': // cursor to column x if ( argbuf[0] > 0 ) { cursor = cursor - ( cursor % columns ) + getArgument( 0 ); } break; ... // other commands go here default: System.err.println( "unsupported command: " + (char) command + " : " + new String( argbuf, 1, argbuf[0] ) ); } } catch ( Throwable t ) { // probably parse exception or wrong number of args System.err.println( "Error in processCommand: " ); t.printStackTrace(); } } |
processCommand()
其实是一个大的 switch 语句,为所支持的每条命令都有一个 case。不支持的命令进入默认 case 并被忽略。这里所列出的两个 case 示范了处理命令的剩余部分的逻辑。
这里您可以了解操纵鼠标所需的数组算法的种类了。记住,我们的二维屏幕是由一维字节数组来表示的,而且我们在内存用尽之前将不会丢弃滚出屏幕顶端的数据。我们尽可能多地保留,以便用户可以滚动回来来查看他们可能漏看的任何内容。由于这个原因,屏幕的原点必须相对于数组的末尾而不是开头来计算,所以原点的位置在第 1 行、第 1 列。
如果我们不支持 scrollback 特性,要计算一对坐标的数组下标就很简单:y * columns + x
。要支持这一特性,我们就需要多做一些工作,并必须相对于我们的显示缓冲区的外界来计算坐标。如果这会使我们的用户满意,做这些额外的工作是值得的。
使其具备交互特性
为了使用户更满意而又不需要太多工作,可使他们能够使用他们设备上的键盘来与远程主机交互。
我们较早的应用程序与远程主机交互的方式,只能是通过执行一组脚本形式的命令。用户只能等待操作完成,然后使用箭头键来滚动数据缓冲区来查看返回的内容。
在下一版本的 MIDTerm 中,我们将用户的键击信号直接发送到远程主机。要发送任何东西,我们都需要一个输出流,所以 TelnetCanvas
现在有一个 setOutputStream()
方法用于此目的。用户输入通过如下修改 keyPressed()
方法来处理:
... private byte[] move = new byte[] { 27, (byte) '[', 0 }; ... public void keyPressed( int keyCode ) { switch ( getGameAction( keyCode ) ) { case LEFT: // move cursor left one column move[2] = 'D'; send( move ); break; case RIGHT: // move cursor right one column move[2] = 'C'; send( move ); break; case DOWN: if ( isScrolling() ) { // scroll down one row scrollY++; if ( scrollY > calcLastVisibleScreen() ) { scrollY = calcLastVisibleScreen(); } repaint(); } else { // move cursor down one row move[2] = 'B'; send( move ); } break; case UP: if ( isScrolling() ) { // scroll up one row scrollY--; if ( scrollY < 0 ) scrollY = 0; repaint(); } else { // move cursor down one row move[2] = 'A'; send( move ); } break; case FIRE: // send a line feed: send( (byte) '/n' ); break; default: // send code directly send( (byte) keyCode ); } } |
要注意的第一件事情是,在决定按哪个键之前我们要使用 getGameAction()
来将按键代码转换为游戏代码。MIDP 设备的键盘布局不同:有些有箭头按键和数字小键盘,有些只有数字小键盘可用作箭头按键。getGameAction()
方法隐藏了这些复杂性。
如果所按的键是 UP、DOWN、LEFT 或
RIGHT,
我们生成相应的 ANSI 命令并将其发送到远程主机。注意 UP
和 DOWN
有两个模式:一个用于移动光标,一个用于滚动显示内容。当前的滚动模式由两种新的方法 isScrolling()
和 setScrolling()
来发现和控制。如果打开滚动,UP
和 DOWN
将滚动输出而不是移动光标。注意,通过直接测试 scrolling
变量而不是调用 isScrolling()
来测试,我们可以节省一些系统开销。
键发送一个换行符,它类似于一般键盘上的 Enter 或 Return 键。这种特性对于基于菜单的应用程序很有用,由箭头键突出显示一个选项并用 Enter 键选择突出显示的选项;Lynx web 浏览器就是一个很好的例子。这些应用程序仅使用用户手持设备上的键盘就可以完全发挥作用。FIRE
如果按键代码没有映射到任何游戏动作,就会将它直接发送到远程主机。MIDP 规范中规定,具有比标准手机的按键多的设备应发送等同的 ASCII 字符作为其按键代码。直接发送这些键代码使得应用程序可以完全利用具有完整键盘的设备的优势。具有这些设备的用户只需开始输入即可;他们的键击会通过要建立的连接而发送。
最后,因为 TelnetCanvas
组件隐藏了来自应用程序的其他部分的所有终端模拟逻辑,我们需要新的方法 getRows()
、getColumns()
和 getTerminalType()
来宣布屏幕尺寸和实现所支持的终端模拟的种类。
更新 MIDlet
与以前一样,MIDlet 类自身 MIDTerm
将 TelnetConnection
和 TelnetCanvas
捆绑在一起。只需要首位改变一下连接的设置:
... connection = new TelnetConnection( (StreamConnection) Connector.open( connectString, Connector.READ_WRITE, true ), canvas.getColumns(), canvas.getRows(), canvas.getTerminalType() ); input = connection.openInputStream(); output = connection.openOutputStream(); canvas.setOutputStream( output ); ... |
但 MIDTerm 需要做更多。虽然 TelnetCanvas
处理基本用户输入,仅有数字键的键盘则需要特殊的方式来输入文字,如重复按某个键来选择某个字母,或预测式文字输入法。利用这些内在功能的唯一途径就是使用 MIDP 的 TextField
和 TextBox
组件。因为这些组件不能与 Canvas
位于相同的屏幕,文字输入需要另外一个屏幕。虽然我们在屏幕上,但应用程序还应提供另一个屏幕来建立连接,这要用到主机名称和要在其上连接的端口的字段。MIDTerm 将用作中心代理来将这些屏幕捆绑在一起。
Form
类非常灵活,所以我们不需要每个类都有一个单独的子类,而这一点很让人高兴。大多数 MIDP 设备用于应用程序存储的空间都有限,并限制应用程序的大小,通常是最大 32 KB 或 64 KB,所以大小问题很重要。应用程序中的每个类都会至少将 JAR 文件的大小增加半个千字节,即使在模糊处理后仍是如此,所以应尽可能地避免创建子类。
输入窗体会经常用到,所以将它创建在 MIDTerm 的构造函数中,并在应用程序运行期间保持待用状态。这种方法消除了创建输入窗体带来的任何可感知的延迟,并避免为窗体进行重复内存分配和垃圾收集而引起的内存扰动(memory churn)。输入窗体的设置非常简单:
... |
TelnetCanvas
会在用户希望发送一些文字时激活该窗体。第一个字段是 TextField
,而且在大部分平台上它都应获得默认焦点。稍微费点功夫,用户就可以快速调用该窗体,输入一些文字,然后选择 OK 命令来发送数据。
因为许多基于终端的应用程序假定用户应采用终端类型的键盘,用户就需要某种方法来发送特殊的键击,如 Return、Escape 或 Control 加按键组合。我们为此而使用 ChoiceGroup
,将其设置为 MULTIPLE
模式,以便我们得到复选框而不是互斥的单选按钮。根据两种复选框设置,用户的文本将在 Escape 字符后发送,或在换行字符后发送。如果选择了 Send as CTRL 选项,发送每个字符时就像是按下了 Control 键一样(Control 加按键代码的计算方法,是将一个字符转换为其大写形式,然后从其值减 64)。Append ENTER 选项默认为 true
,因为可用性测试表明大多数文字输入后跟的是一个换行符。
最后,该窗体是允许客户设置 TelnetCanvas
的滚动模式的一个很好的地方。这一特性在功能上等同于终端键盘上的 Scroll Lock 键,而复选框也同样地进行标记。为了进行可视区分,这个选项加入一个单独的 ChoiceGroup。
登录窗体在一般的应用程序生命周期内很少会用到两次以上,所以我们只根据需要来创建它,当不用时就将其清除。其创建和布局在 onShowLogin()
中进行:
|
虽然这是一个简单的窗体,可用性仍是我们最重要的考虑。一些实现可以利用一个 TextField
(URL
或 NUMERIC
)上的“线索”并适当地定制用户界面。因为用户通常将在 Host 中输入服务器的域名,指出所期望的用户输入像一个 URL 就很有意义。某些设备可能有特殊的屏幕布局,它们针对字母和符号而不是数字进行优化。同样地,Port 字段应仅限于数字输入;一些设备可能允许用户在它们的键盘上快速输入数字,而绕过任何重复按键式文字输入系统。
MIDlet 是登录窗体、输入窗以及 Telnet canvas 的命令监听器。可将其视作应用程序工作流的向导。
在启动时,将出现登录窗体,选项为 Open 或 Exit。 一旦打开,就会出现 Telnet canvas,选项为 Close 或 Input。Input 选项显示输入窗体,其中有一个 OK 选项用于隐藏窗体并发送文字,另有一个 Cancel 选项仅仅隐藏窗体。Close 选项隐藏 Telnet canvas 并显示一个登录窗体。最后,Exit 选项调用 notifyDestroyed()
并退出应用程序。所有这些命令由 MIDTerm
的 commandAction()
方法来处理。
让我们使它工作起来
现在所有组件已经就绪,我们就可以使我们的终端模拟器工作起来了。终端应用程序在企业和教育计算环境中得到广泛应用,用于企业应用程序、软件开发、系统管理甚至对策模拟。MIDTerm 使得所有这些种类的应用程序和资源都可以从您的移动设备来访问。
软件开发: emacs
![]() |
系统管理: top
![]() |
Gaming: starcross
![]() |
更进一步,任何这些应用程序都可以被“刮擦”:您可以编写一个移动应用程序来与远程服务器上的资源密集型程序进行交互、提取输出并将它呈现在一个用户友好的图形界面上。企业开发者通常使用这种技术来在遗留系统上构造新式的用户界面。
MIDP 平台上的有效 Telnet 和 ANSI 终端实现清除了这些种类的软件项目的主要障碍。
结束语
通过实现对 ANSI 的终端转义序列的支持,我们已经更新了第一篇文章的未完善的终端显示。这个应用程序还更好地利用了 MIDP 的用户界面功能,并具有对用于用户输入的键盘和定制窗体的支持。您可以组合和匹配这些软件组建来为新的种类的网络意识(network-aware)移动应用程序提供基础。