J2ME最佳实践

廖雪峰 / 文章 / ... / Reads: 2897 Edit

概述

J2ME是Sun发布的运行在小型设备上的微型版Java的一系列标准,其中,最重要的标准便是运行在手机上的MIDP应用程序了。到目前为止,MIDP一共发布了两个版本:MIDP 1.0(JSR37)和MIDP 2.0(JSR118),2.0版本可以向后兼容1.0版本,也就是说,支持MIDP 2.0的手机可以同时运行MIDP 1.0和MIDP 2.0的应用程序。本文将重点讲述开发MIDP应用程序时非常有用的一些设计模式,开发技巧以及如何调试、优化J2ME应用程序。

本文将讨论J2ME开发的以下内容:

  • 如何自动适应用户手机配置
  • 如何在屏幕间导航
  • 如何实现一个灵活的联网应用
  • 如何实现一个灵活的RMS应用
  • 如何调试并优化J2ME程序

避免OutOfMemoryError

对于MIDP应用程序来说,由于手机设备上的资源非常有限,较弱的CPU计算能力,有限的内存(从几十KB到几百KB,虽然少数高端手机拥有超过1M的动态内存),很小的屏幕尺寸,因此,为了让一个MIDP应用程序能够不加改动地在多种不同手机上运行,程序必须有能力根据系统配置自动调整运行时的参数。比如,对于内存非常小的手机,如果从网络下载一幅较大的图像,需要分配巨大的缓冲区,就可能导致OutOfMemoryError错误,使应用程序直接终止,这会使用户感到不知所措,或者丢失用户的重要数据。因此,在试图分配一块大内存之前,首先使用System.gc()尝试让垃圾收集器释放无用对象占用的内存,然后,使用Runtime.getRuntime().freeMemory()方法获得可用的内存空间。如果可用空间太小,给用户一个“内存不足,无法完成操作”的Alert提示,从而尽可能地避免OutOfMemoryError错误。

// 示例代码:
System.gc();
int max_size = 102400; // 100KB
int free_size = (int)Runtime.getRuntime().freeMemory();
if(max_size<free_size*2/3) {
    // TODO: Alert!
}
else {
    byte[] buffer = new byte[max_size];
    // TODO: Download image...
}

减少图片以减小JAR文件大小

许多手机会因为JAR文件太大而无法运行MIDP应用程序,而减小JAR文件尺寸的有效方法之一是减少不必要的图片,例如,启动时的LOGO图片可以用文字来代替,列表项可以只显示文字而不显示图片。为了能适应不同配置的手机,我们的代码就应该编写得更加灵活。例如,从JAR包中加载图片时:

Image image = null;
try {
    image = Image.createImage("/logo.png");
}
catch(Exception ioe) {}
if(image==null) {
    g.setColor(0);
    g.drawString("info", getWidth()/2, getHeight()/2, Graphics.HCENTER|Graphics.BASELINE);
}
else {
    g.drawImage(image, getWidth()/2, getHeight()/2, Graphics.HCENTER|Graphics.VCENTER);
}

如果加载失败,程序会以文字方式显示,这样,对于低配置的手机,只需要把美化界面的图片删除掉,再重新打包即可得到一个可发布的尺寸较小的JAR包,同时应用程序的代码并没有改动。

类似的,在加载List之类的UI组件时:

Image image = null;
try {
    image = Image.createImage("/logo.png");
}
catch(Exception ioe) {}
append("label", image);

这使得有无图片仅仅影响界面美观,而不影响应用程序的功能。

获取设备支持的可选API

J2ME规范包括了许多可选包,如支持多媒体功能的MMAPI,支持消息接收和发送的WMA,支持3D游戏的M3G API。如果某一款手机支持某个可选API,MIDP应用程序就可以使用它。但是,让用户回答“本机是否支持MMAPI”是不友好的,发布几个不同版本不但增加了开发的工作量,也让用户难以选择。因此,应用程序应该自己检测手机是否支持某一API,从而在运行期决定是否可以使用此API。

MIDP 1.0和2.0应用程序都可以通过System.getProperty(String key)检测某一个属性的信息。如果该属性有效,将返回对应的字符串,否则,返回null,表示系统不支持此功能。

例如,System.getProperty("microedition.profiles")可能的返回值是"MIDP-1.0"或"MIDP-2.0"。

以下是常见的系统属性和可选API的属性,右侧列出了可能的返回值:

系统信息
microedition.platform平台名称,如j2me
microedition.configurationCLDC或CDC版本,如CLDC-1.0
microedition.profilesMIDP版本,如MIDP-1.0
microedition.encoding默认的系统编码,如GBK
microedition.locale默认的区域设置,如zh-CN
MMAPI相关
microedition.media.versionMMAPI的版本,如1.1
supports.mixing是否支持混音,如true
supports.audio.capture是否支持音频捕获,如true
supports.video.capture是否支持视频捕获,如true
supports.recording是否支持录音,如true
audio.encodings音频编码格式,如encoding=pcm encoding=pcm&rate=8000&bits=8&channels=1
video.snapshot.encodings拍摄图片的编码格式,如encoding=jpeg encoding=png
streamable.contents支持的流媒体格式,如audio/x-wav
WMA相关
wireless.messaging.sms.smsc返回SMS的服务中心,如+8613800010000
wireless.messaging.mms.mmsc返回MMS的服务中心,如http://mmsc.monternet.com
其他
microedition.m3g.version返回Mobile 3D的版本,如1.0
bluetooth.api.version返回蓝牙API的版本,如1.0
microedition.io.file.FileConnection.version返回FileConnection的版本,如1.0
microedition.pim.version返回PIM的版本,如1.0

例如,如果用户的手机内置了数码相机,并且支持MMAPI,我们就可以在MIDP程序中拍摄照片。因此,在应用程序启动时就应该判断是否启用拍照功能以及用户手机支持的图片编码格式:

boolean supports_take_photo = false;
boolean supports_jpeg_encoding = false;
boolean supports_png_encoding = false;
boolean supports_gif_encoding = false;
if(System.getProperty("microedition.media.version")!=null) {
    if("true".equals(System.getProperty("supports.video.capture")))
        supports_take_photo = true;
        String all_encoding = System.getProperty("video.snapshot.encodings");
        if(all_encoding!=null) {
            if(all_encoding.indexOf("jpeg")!=(-1))
                supports_jpeg_encoding = true;
            if(all_encoding.indexOf("png")!=(-1))
                supports_png_encoding = true;
            if(all_encoding.indexOf("gif")!=(-1))
                supports_gif_encoding = true;
        }
    }
}

屏幕导航

除了游戏程序,在通常的MIDP应用程序中,通常会有很多个Screen或Canvas,这些屏幕一般靠命令来实现切换,比如用户点击“Next”应该跳到下一屏,点击“Back”应该返回到上一屏。当屏幕数量相当可观时,如何在各个屏幕之间导航就值得好好考虑了。

经典的MVC模式可用于屏幕导航,Model用于存储应用程序数据,而View则是各个Displayable对象,Controller需要单独的一个类实现。由于MIDlet类本身在生命周期内就只有一个实例,因此MIDlet类就非常适合作为Controller。SUN在blueprints示例程序SmartTicket中应用了非常复杂的MVC,完全可以满足MIDP应用程序的导航需要,但是可以看出,缺点是很明显的:

一是每一个事件都需要一个唯一标识,switch-case语句会随着屏幕的增加而增加,Controller变得难以维护。二是Controller引用了所有的View,这些View在程序启动时就被初始化导致很大的内存开销,而不管它们是否会被显示。三是大量的Model对象以及异常处理都使得整个应用程序的逻辑大大复杂。

实际上,MIDP应用程序的很多屏幕并不需要复杂的Controller和Model,我们的目标是满足基本的灵活性的同时保持结构简单。因此,另外两种导航方法是用二叉树和堆栈实现,这里我们只讨论用堆栈实现的MIDP导航框架,其基本思想是:每当前进到下一个屏幕时,先将下一个屏幕压栈,然后再显示;当返回到上一个屏幕时,先从堆栈中弹出当前屏幕,再从堆栈中取出上一个屏幕并显示。因此,每个屏幕只需要指定要显示的下一个屏幕,而不需记住上一个屏幕。这种堆栈导航模型特别适合有规律的“前进”、“后退”屏幕。

由于MIDlet类运行期只有一个实例,因此,使用MIDlet类作为控制器相当合适。此外,我们在一个静态变量中保存了MIDlet实例,使得访问MIDlet更加方便:

public class ControllerMIDlet extends MIDlet {

    private static ControllerMIDlet instance = null;
    private Display display = null;
    private Stack ui = new Stack();

    public ControllerMIDlet() { instance = this; }

    protected void startApp() {}

    protected void pauseApp() {}

    protected void destroyApp(boolean unconditional) {}

    public static void goBack() {
        instance.ui.pop();
        Object obj = instance.ui.peek();
        instance.display.setCurrent((Displayable)obj);
    }

    public static void forward(Displayable next) {
        instance.ui.push(next);
        instance.display.setCurrent(next);
    }

}

让我们更详细地研究一下实际的应用程序可能出现的几种屏幕跳转情况。最简单的情况是,从一个屏幕前进到另一个屏幕,且返回时仍回到原先的屏幕,这种情况完全符合堆栈的FIFO特点,可以直接调用ControllerMIDlet的forward和goBack方法即可。例如,要显示一个帮助屏幕:

j2me-nav-1

对于一个联网的应用程序,另一种情况是有一个暂时的等待屏幕。下面是一个在线浏览图片的屏幕:

j2me-nav-2

与上面的情况所不同的是,如果用户在屏幕3选择“返回”,则应当回到屏幕1而不是屏幕2,因此,对于屏幕2到屏幕3的切换,就不能forward,我们使用replace,抛弃屏幕2,从而实现屏幕3直接可以goBack到屏幕1:

public static void replace(Displayable next) {

    instance.ui.pop();

    instance.ui.push(next);

    instance.display.setCurrent(next);

}

堆栈的变化如下:

j2me-nav-3

对于某些更为复杂的情况,例如,登录过程,如果允许用户选择自动登录,则屏幕跳转如下:

j2me-nav-4

如果用户不选择自动登录,则屏幕跳转如下:

j2me-nav-5

对于这种情况,解决方案是,即使用户选择了自动登录,LoginUI屏幕也要被压入堆栈中,但是不显示出来,因此,我们定义了另一个forward(Displayable d1, Displayable d2)方法,它将d1和d2依次压入堆栈,但只显示d2。在返回时,如果用户取消,则返回到LoginUI。总之,通过定义多个导航方法,就可以实现各种操作。

这种基于堆栈的导航模型非常适用于有规律的“前进”,“后退”屏幕,而且只在需要的时候生成新的屏幕。无需关心屏幕状态,因为返回时上一个屏幕的状态被完整地保存在堆栈中。

堆栈模型的缺点是数据由不同的屏幕处理,对于一些流程而言,可能需要将每个屏幕的数据依次传递给下一个屏幕,越往后的屏幕其构造方法的参数可能也越多。

对于联网操作等涉及到多线程等待屏幕的情况,我们将在后面给出一个完整的解决方案,并集成到堆栈导航框架中,使应用程序本身完全不用涉及到多线程联网操作,只需专注于自身逻辑。

编写反应灵敏的联网提示界面

由于无线设备所能支持的网络协议非常有限,仅限于HTTP,Socket,UDP等几种协议,不同的厂家可能还支持其他网络协议,但是,MIDP 1.0规范规定,HTTP协议是必须实现的协议,而其他协议的实现都是可选的。因此,为了能在不同类型的手机上移植,我们尽量采用HTTP作为网络连接的首选协议,这样还能重用服务器端的代码。但是,由于HTTP是一个基于文本的效率较低的协议,因此,必须仔细考虑手机和服务器端的通信内容,尽可能地提高效率。

对于MIDP应用程序,应当尽量做到:

  • 发送请求时,附加一个User-Agent头,传入MIDP和自身版本号,以便服务器能识别此请求来自MIDP应用程序,并且根据版本号发送相应的相应。
  • 连接服务器时,显示一个下载进度条使用户能看到下载进度,并能随时中断连接。
  • 由于无线网络连接速度还很慢,因此有必要将某些数据缓存起来,可以存储在内存中,也可以放到RMS中。

对于服务器端而言,其输出响应应当尽量做到:

  • 明确设置Content-Length字段,以便MIDP应用程序能读取HTTP头并判断自身是否有能力处理此长度的数据,如果不能,可以直接关闭连接而不必继续读取HTTP正文。
  • 服务器不应当发送HTML内容,因为MIDP应用程序很难解析HTML,XML虽然能够解析,但是耗费CPU和内存资源,因此,应当发送紧凑的二进制内容,用DataOutputStream直接写入并设置Content-Type为application/octet-stream。
  • 尽量不要重定向URL,这样会导致MIDP应用程序再次连接服务器,增加了用户的等待时间和网络流量。
  • 如果发生异常,例如请求的资源未找到,或者身份验证失败,通常,服务器会向浏览器发送一个显示出错的页面,可能还包括一个用户登录的Form,但是,向MIDP发送错误页面毫无意义,应当直接发送一个404或401错误,这样MIDP应用程序就可以直接读取HTTP头的响应码获取错误信息而不必继续读取相应内容。
  • 由于服务器的计算能力远远超过手机客户端,因此,针对不同客户端版本发送不同响应的任务应该在服务器端完成。例如,根据客户端传送的User-Agent头确定客户端版本。这样,低版本的客户端不必升级也能继续使用。

MIDP的联网框架定义了多种协议的网络连接,但是每个厂商都必须实现HTTP连接,在MIDP 2.0中还增加了必须实现的HTTPS连接。因此,要保证MIDP应用程序能在不同厂商的手机平台上移植,最好只使用HTTP连接。虽然HTTP是一个基于文本的效率较低的协议,但是由于使用特别广泛,大多数服务器应用的前端都是基于HTTP的Web页面,因此能最大限度地复用服务器端的代码。只要控制好缓存,仍然有不错的速度。

SUN的MIDP库提供了javax.microediton.io包,能非常容易地实现HTTP连接。但是要注意,由于网络有很大的延时,必须把联网操作放入一个单独的线程中,以避免主线程阻塞导致用户界面停止响应。事实上,MIDP运行环境根本就不允许在主线程中操作网络连接。因此,我们必须实现一个灵活的HTTP联网模块,能让用户非常直观地看到当前上传和下载的进度,并且能够随时取消连接。

一个完整的HTTP连接为:用户通过某个命令发起连接请求,然后系统给出一个等待屏幕提示正在连接,当连接正常结束后,前进到下一个屏幕并处理下载的数据。如果连接过程出现异常,将给用户提示并返回到前一个屏幕。用户在等待过程中能够随时取消并返回前一个屏幕。

我们设计一个HttpThread线程类负责在后台连接服务器,HttpListener接口实现Observer(观察者)模式,以便HttpThread能提示观察者下载开始、下载结束、更新进度条等。HttpListener接口如下:

public interface HttpListener {

    void onSetSize(int size);

    void onFinish(byte[] data, int size);

    void onProgress(int percent);

    void onError(int code, String message);

}

实现HttpListener接口的是继承自Form的一个HttpWaitUI屏幕,它显示一个进度条和一些提示信息,并允许用户随时中断连接:

public class HttpWaitUI extends Form implements CommandListener, HttpListener {

    private Gauge gauge;
    private Command cancel;
    private HttpThread downloader;
    private Displayable displayable;

    public HttpWaitUI(String url, Displayable displayable) {
        super("Connecting");
        this.gauge = new Gauge("Progress", false, 100, 0);
        this.cancel = new Command("Cancel", Command.CANCEL, 0);
        append(gauge);
        addCommand(cancel);
        setCommandListener(this);
        downloader = new HttpThread(url, this);
        downloader.start();
    }

    public void commandAction(Command c, Displayable d) {
        if(c==cancel) {
            downloader.cancel();
            ControllerMIDlet.goBack();
        }
    }

    public void onFinish(byte[] buffer, int size) { … }

    public void onError(int code, String message) { … }

    public void onProgress(int percent) { … }

    public void onSetSize(int size) { … }

}

HttpThread是负责处理Http连接的线程类,它接受一个URL和HttpListener:

class HttpThread extends Thread {

    private static final int MAX_LENGTH = 20 * 1024; // 20K

    private boolean cancel = false;
    private String url;
    private byte[] buffer = null;
    private HttpListener listener;

    public HttpThread(String url, HttpListener listener) {
        this.url = url;
        this.listener = listener;
    }

    public void cancel() { cancel = true; }

}

使用GET获取内容

我们先讨论最简单的GET请求。GET请求只需向服务器发送一个URL,然后取得服务器响应即可。在HttpThread的run()方法中实现如下:

public void run() {
    HttpConnection hc = null;
    InputStream input = null;
    try {
        hc = (HttpConnection)Connector.open(url);
        hc.setRequestMethod(HttpConnection.GET); // 默认即为GET
        hc.setRequestProperty("User-Agent", USER_AGENT);
        // get response code:
        int code = hc.getResponseCode();
        if(code!=HttpConnection.HTTP_OK) {
            listener.onError(code, hc.getResponseMessage());
            return;
        }
        // get size:
        int size = (int)hc.getLength(); // 返回响应大小,或者-1如果大小无法确定
        listener.onSetSize(size);
        // 开始读响应:
        input = hc.openInputStream();
        int percent = 0; // percentage
        int tmp_percent = 0;
        int index = 0; // buffer index
        int reads; // each byte
        if(size!=(-1))
            buffer = new byte[size]; // 响应大小已知,确定缓冲区大小
        else
            buffer = new byte[MAX_LENGTH]; // 响应大小未知,设定一个固定大小的缓冲区
        while(!cancel) {
            int len = buffer.length - index;
            len = len&gt;128 ? 128 : len;
            reads = input.read(buffer, index, len);
            if(reads<=0)
                break;
            index += reads;
            if(size>0) { // 更新进度
                tmp_percent = index * 100 / size;
                if(tmp_percent!=percent) {
                    percent = tmp_percent;
                    listener.onProgress(percent);
                }
            }
        }
        if(!cancel && input.available()>0) // 缓冲区已满,无法继续读取
            listener.onError(601, "Buffer overflow.");
        if(!cancel) {
            if(size!=(-1) && index!=size)
                listener.onError(102, "Content-Length does not match.");
            else
                listener.onFinish(buffer, index);
        }
    }
    catch(IOException ioe) {
        listener.onError(101, "IOException: " + ioe.getMessage());
    }
    finally { // 清理资源
        if(input!=null)
            try { input.close(); } catch(IOException ioe) {}
        if(hc!=null)
            try { hc.close(); } catch(IOException ioe) {}
    }
}

当下载完毕后,HttpWaitUI就获得了来自服务器的数据,要传递给下一个屏幕处理,HttpWaitUI必须包含对此屏幕的引用并通过一个setData(DataInputStream input)方法让下一个屏幕能非常方便地读取数据。因此,定义一个DataHandler接口:

public interface DataHandler {

    void setData(DataInputStream input) throws IOException;

}

HttpWaitUI响应HttpThread的onFinish事件并调用下一个屏幕的setData方法将数据传递给它并显示下一个屏幕:

public void onFinish(byte[] buffer, int size) {
    byte[] data = buffer;
    if(size!=buffer.length) {
        data = new byte[size];
        System.arraycopy(data, 0, buffer, 0, size);
    }
    DataInputStream input = null;
    try {
        input = new DataInputStream(new ByteArrayInputStream(data));
        if(displayable instanceof DataHandler)
            ((DataHandler)displayable).setData(input);
        else
            System.err.println("[WARNING] Displayable object cannot handle data.");
        ControllerMIDlet.replace(displayable);
    }
    catch(IOException ioe) { … }
}

以下载一则新闻为例,一个完整的HTTP GET请求过程如下:

首先,用户通过点击某个屏幕的命令希望阅读指定的一则新闻,在commandAction事件中,我们初始化HttpWaitUI和显示数据的NewsUI屏幕:

public void commandAction(Command c, Displayable d) {

    HttpWaitUI wait = new HttpWaitUI("http://192.168.0.1/news.do?id=1", new NewsUI());

    ControllerMIDlet.forward(wait);

}

NewsUI实现DataHandler接口并负责显示下载的数据:

public class NewsUI extends Form implements DataHandler {

    public void setData(DataInputStream input) throws IOException {
        String title = input.readUTF();
        Date date = new Date(input.readLong());
        String text = input.readUTF();
        append(new StringItem("Title", title));
        append(new StringItem("Date", date.toString()));
        append(text);
    }

}

服务器端只要以String, long, String的顺序依次写入DataOutputStream,MIDP客户端就可以通过DataInputStream依次取得相应的数据,完全不需要解析XML之类的文本,非常高效而且方便。

需要获得联网数据的屏幕只需实现DataHandler接口,并向HttpWaitUI传入一个URL即可复用上述代码,无须关心如何连接网络以及如何处理用户中断连接。

使用POST发送数据

以POST方式发送数据主要是为了向服务器发送较大量的客户端的数据,它不受URL的长度限制。POST请求将数据以URL编码的形式放在HTTP正文中,字段形式为fieldname=value,用&分隔每个字段。注意所有的字段都被作为字符串处理。实际上我们要做的就是模拟浏览器POST一个表单。以下是IE发送一个登陆表单的POST请求:

POST http://127.0.0.1/login.do HTTP/1.0
Accept: image/gif, image/jpeg, image/pjpeg, */*
Accept-Language: en-us,zh-cn;q=0.5
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)
Content-Length: 28

username=admin&password=1234

要在MIDP应用程序中模拟浏览器发送这个POST请求,首先设置HttpConnection的请求方式为POST:

hc.setRequestMethod(HttpConnection.POST);

然后构造出HTTP正文:

byte[] data = "username=admin&password=1234".getBytes();

并计算正文长度,填入Content-Type和Content-Length:

hc.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
hc.setRequestProperty("Content-Length", String.valueOf(data.length));

然后打开OutputStream将正文写入:

OutputStream output = hc.openOutputStream();
output.write(data);

需要注意的是,数据仍需要以URL编码格式编码,由于MIDP库中没有J2SE中与之对应的URLEncoder类,因此,需要自己动手编写这个encode()方法,可以参考java.net.URLEncoder.java的源码。剩下的便是读取服务器响应,代码与GET一致,这里就不再详述。

使用multipart/form-data发送文件

如果要在MIDP客户端向服务器上传文件,我们就必须模拟一个POST multipart/form-data类型的请求,Content-Type必须是multipart/form-data。

以multipart/form-data编码的POST请求格式与application/x-www-form-urlencoded完全不同,multipart/form-data需要首先在HTTP请求头设置一个分隔符,例如ABCD:

hc.setRequestProperty("Content-Type", "multipart/form-data; boundary=ABCD");

然后,将每个字段用“--分隔符”分隔,最后一个“--分隔符--”表示结束。例如,要上传一个title字段"Today"和一个文件C:\1.txt,HTTP正文如下:

--ABCD
Content-Disposition: form-data; name="title"

Today
--ABCD
Content-Disposition: form-data; name="1.txt"; filename="C:\1.txt"
Content-Type: text/plain

<这里是1.txt文件的内容>
--ABCD--

请注意,每一行都必须以\r\n结束,包括最后一行。如果用Sniffer程序检测IE发送的POST请求,可以发现IE的分隔符类似于---------------------------7d4a6d158c9,这是IE产生的一个随机数,目的是防止上传文件中出现分隔符导致服务器无法正确识别文件起始位置。我们可以写一个固定的分隔符,只要足够复杂即可。

发送文件的POST代码如下:

String[] props = ... // 字段名
String[] values = ... // 字段值
byte[] file = ... // 文件内容
String BOUNDARY = "---------------------------7d4a6d158c9"; // 分隔符
StringBuffer sb = new StringBuffer();
// 发送每个字段:
for(int i=0; i<props.length; i++) {
    sb = sb.append("--");
    sb = sb.append(BOUNDARY);
    sb = sb.append("\r\n");
    sb = sb.append("Content-Disposition: form-data; name=\""+ props[i] + "\"\r\n\r\n");
    sb = sb.append(URLEncoder.encode(values[i]));
    sb = sb.append("\r\n");
}
// 发送文件:
sb = sb.append("--");
sb = sb.append(BOUNDARY);
sb = sb.append("\r\n");
sb = sb.append("Content-Disposition: form-data; name=\"1\"; filename=\"1.txt\"\r\n");
sb = sb.append("Content-Type: application/octet-stream\r\n\r\n");
byte[] data = sb.toString().getBytes();
byte[] end_data = ("\r\n--" + BOUNDARY + "--\r\n").getBytes();
// 设置HTTP头:
hc.setRequestProperty("Content-Type", MULTIPART_FORM_DATA + "; boundary=" + BOUNDARY);
hc.setRequestProperty("Content-Length", String.valueOf(data.length + file.length + end_data.length));
// 输出:
output = hc.openOutputStream();
output.write(data);
output.write(file);
output.write(end_data);
// 读取服务器响应:
// TODO...

使用Cookie保持Session

通常服务器使用Session来跟踪会话。Session的简单实现就是利用Cookie。当客户端第一次连接服务器时,服务器检测到客户端没有相应的Cookie字段,就发送一个包含一个识别码的Set-Cookie字段。在此后的会话过程中,客户端发送的请求都包含这个Cookie,因此服务器能够识别出客户端曾经连接过服务器。

要实现与浏览器一样的效果,MIDP应用程序必须也能识别Cookie,并在每个请求头中包含此Cookie。

在处理每次连接的响应中,我们都检查是否有Set-Cookie这个头,如果有,则是服务器第一次发送的Session ID,或者服务器认为会话超时,需要重新生成一个Session ID。如果检测到Set-Cookie头,就将其保存,并在随后的每次请求中附加它:

String session = null;
String cookie = hc.getHeaderField("Set-Cookie");
if(cookie!=null) {
    int n = cookie.indexOf(';');
    session = cookie.substring(0, n);
}

使用Sniffer程序可以捕获到不同的Web服务器发送的Session。WebLogic Server 7.0返回的Session如下:

Set-Cookie: JSESSIONID=CxP4FMwOJB06XCByBWfwZBQ0IfkroKO2W7FZpkLbmWsnERuN5u2L!-1200402410; path=/

而Resin 2.1返回的Session则是:

Set-Cookie: JSESSIONID= aTMCmwe9F5j9; path=/

运行ASP.Net的IIS返回的Session:

Set-Cookie: ASPSESSIONIDQATSASQB=GNGEEJIDMDFCMOOFLEAKDGGP; path=/

我们无须关心Session ID的内容,服务器自己会识别它。我们只需在随后的请求中附加上这个Session ID即可:

if(session!=null)
    hc.setRequestProperty("Cookie", session);

对于URL重写来保持Session的方法,在PC客户端可能很有用,但是,由于MIDP程序很难分析出URL中有用的Session信息,因此,不推荐使用这种方法。

编写灵活的RMS应用

MIDP应用程序的标准持久化方案就是使用RMS。RMS类似于一个小型数据库,RecordStore相当于数据库的表,每个“表”由若干记录(Record)构成,一条记录就是一个用int表示的记录号RecordID和用byte[]表示的内容。记录号可以看作是“主键”,byte[]数组存储内容。

RMS提供的记录操作可以实现根据ID直接获得记录,或者枚举出一个表中的所有记录。

枚举记录是非常低效的,因为只能比较byte[]数据来确定该记录是否是所需的记录。通过ID获得记录是高效而方便的,类似于SQL语句“SELECT byteArrayData FROM recordStoreName WHERE RecordID=?”。然而,通常应用程序很难知道某条记录的ID号,而RMS记录的“主键”又仅限于int类型,无法使用其他类型如String作为“主键”来查找。因此,对于需要存取不同类型对象的应用程序而言,就需要一个灵活的RMS操作框架。

我们的基本设想是,如果能使用String作为“主键”来查找记录,就能非常方便地获得所需的内容。例如,应用程序设置可以通过"sys.settings"获得byte[]数组,并依次读取出设置,用户登录信息可以通过"user.info"获得byte[]数组,再分解出用户名和口令。

因此,我们实现一个StorageHandler类,提供唯一的RMS访问接口,使得其他类完全不必考虑底层的RMS操作,只需提供能标识自身的一个String即可。

如果我们能实现一种类似于数据库索引的查找表,就能根据String关键字查找某条记录。因此,我们使用一个名为"index"的RecordStore来存储所有的索引,每一条索引都指向某一条具体记录的ID,设计一个IndexEntry表示一条索引:

class IndexEntry {

    private int selfId;   // IndexEntry的ID
    private int recordId; // 对应记录的ID
    private String key;   // 访问记录的Key

}

根据索引查找,分3步进行:

  1. 在名为"index"的RecordStore中根据String查找对应的IndexEntry。
  2. 取出IndexEntry,获得记录ID号。
  3. 根据ID号获得另一个RecordStore的记录,然后就可以读取、更新和删除该记录。

如下图所示:

j2me-rms-1

由于IndexEntry保存的数据很少,为了加快查找速度,可以在应用程序启动时,把所有的IndexEntry读入一个Vector,在后面的操作中更新这个Vector并与RecordStore保持同步。

为了处理不同类型的数据,所有可通过StorageHandler存取的类都必须实现一个Storable接口:

public interface Storable {

    String getKey();
    void getData(DataOutputStream output) throws IOException;
    void setData(DataInputStream input) throws IOException;

}

前面已经提到,在MIDP应用程序中,序列化一个类的最佳方法是使用DataInputStream和DataOutputStream。因此,需要持久化的类可以通过getData()和setData()方法非常方便地存取。假定应用程序的类UserInfo保存了用户的登录名、口令和是否自动登录的信息:

public class UserInfo {

    String username;
    String password;
    boolean autoLogin;

}

为了能将UserInfo存入RMS,需要实现Storable接口:

class UserInfo implements Storable {

    String username;
    String password;
    boolean autoLogin;

    public String getKey() { return "user.info"; } // 提供一个唯一标识符即可

    public void getData(DataOutputStream output) throws IOException {
        output.writeUTF(username);
        output.writeUTF(password);
        output.writeBoolean(autoLogin);
    }

    public void setData(DataInputStream input) throws IOException {
        username = input.readUTF();
        password = input.readUTF();
        autoLogin = input.readBoolean();
    }

    // getters here...

}

要保存UserInfo,只需调用StorageHandler的保存方法:

StorageHandler.storeOrUpdate(userinfo);

要读取UserInfo,调用StorageHandler的读取方法:

UserInfo userinfo = new UserInfo();
StorageHandler.load(userinfo);

这样,需要读取或保存数据的类完全不必涉及底层的RMS操作,大大简化了应用程序的设计,增强了源代码的可复用性与可维护性。

Comments

Make a comment

Author: 廖雪峰

Publish at: ...

关注公众号不定期领红包:

加入知识星球社群:

关注微博获取实时动态: