65.9K
CodeProject 正在变化。 阅读更多。
Home

从未有过 Android 经验的人将 Java Applet 移植到 Android 的冒险

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.67/5 (16投票s)

2011年10月17日

CPOL

10分钟阅读

viewsIcon

125203

downloadIcon

5230

冲击物理教程。

Main play screen

引言

这展示了我是如何在没有任何 Android 经验的情况下将 Java Applet 移植到 Android 应用的。

背景

我要感谢几位在启发我编写此应用方面发挥关键作用的人。

1996 年,IBM 的 John Henckel 发布了一个 Java Applet,名为 Impact.java,它是此应用的核心。

此应用程序的总体目的是创建具有质量、大小、颜色、恢复系数等属性的 2D 球体对象,然后将它们放置在具有一定粘度的介质中。球体通过重力相互吸引并相互作用,当它们碰撞时,会根据它们的恢复系数反弹。用户还可以通过鼠标“抓取”球体并改变它们的运动。

我将此 Java Applet 移植到 Android,本文讨论了所需的学习步骤,并重点介绍了如何实现相当常见的 Android 功能,例如菜单、声音、加速度计读数、通知、活动、意图等。Android 应用通过添加声音和使用加速度计传感器来影响球体运动,使其超越重力和摩擦力,从而扩展了原始小程序。

Android 入门

有几个很好的 Android 入门教程。我推荐 http://www.makeuseof.com/tag/write-google-Android-application/ 来了解如何开始编写 Hello World 应用以及如何安装和配置开发工具。

Mike Waddel 发布了一篇优秀的 CodeProject 文章 https://codeproject.org.cn/KB/Android/TiltBallWalkthrough.aspx,它帮助我开始了解编写此 Android 应用的细节。

当我考虑写这篇文章时,我打算复制上述文章的大部分内容,但后来决定这样做是浪费时间,因为这大部分都是复制粘贴。所以我建议阅读上述文章,了解如何从头开始构建 Android 应用程序。我将更侧重于如何移植和扩展此应用的具体细节。

原始 Java Applet

原始 Applet 如下所示。它被写成一个 Java 文件,名为 Impact.java,并包含多个类。

AppletViewer

Main play screen

在原始 Applet 中,有一个 MainWindow ,它主要包含 UI 元素,还有一个 canvas ,其中 Animator 会绘制 Ball 对象。动画的速度由 Ticker 控制。这是一个简单的类图,不要纠结于 UML 的纯粹性,这张图只是一个概述。顶级类 Impact 创建了一个 Ticker 实例和一个 Animator 实例。Animator 依赖 Ticker 进行时间同步。Animator 创建了一组 Ball 对象。在运行时,Animator 既协调 Ball 之间的交互,又重新绘制屏幕。

原始 Impact.java 类图 移植到 Android 之前

UML class diagram

需要移植关注的代码亮点

以下代码片段是我在移植到 Android 时立即发现问题或在尝试使用 Android SDK 导入和编译时发现问题的区域。主要的移植工作是将 java.awt.* 转换为等效的 Android API。本文顶部引用了原始源代码

//
// The main applet code
public class Impact extends java.applet.Applet{
...
    MainWindow b;   //a AWT feature
...
}

// This class controls the main window
class MainWindow extends Frame{	//Frame is also AWT
...
    Dialog db;          		// Dialog is AWT
    //Note the widgets (Panel, Button, Label, Slider, etc) all have to change
    Panel p = new Panel();      	// control panel
    Label n = new Label("Impact 2.2");
    ...
    
    //uses Java threads, these should port fairly easy unless there is a better way 
    //to implement the functionality
    public void start() 
    {
        if (anim_thread==null)
        {
            anim_thread = new Thread(anim);
            anim_thread.start(); 	// start new thread
        }
        if (tick_thread==null)
        {
            tick_thread = new Thread(tick);
            tick_thread.start();  	// start new thread
        }
    }
    
    // This handles user input events, this should be roughly similar to Android
    // but the details of the API will differ
    public boolean action(Event e, Object arg)
}

// This class performs the animation in the main canvas.
class Animator extends Canvas implements Runnable{
...
    //paint is from Canvas, this will definitely be different in Android
    public void paint(Graphics g) {...}

    // from "implements Runnable", will need porting
    public void run()
    
    //these should port easily
    //The following mouse methods allow you to drag balls
    public boolean mouseDown(Event e, int x, int y) 
    public boolean mouseDrag(Event e, int x, int y) 
    public boolean mouseUp(Event e, int x, int y) 
}

//  The Ball class
class Ball {...} {
    //the Graphics api will need to be changed to Android equivalent
    public void draw(Graphics g, boolean sm) 
    {
        g.setColor(Color.black);
        g.drawOval((int)(ox-z),(int)(oy-z),(int)(2*z),(int)(2*z));
        ox = x; oy = y;           // save new location
        g.setColor(c);
        g.drawOval((int)(x-z),(int)(y-z),(int)(2*z),(int)(2*z));
    }

// Class that serves as a pacemaker for the animator
// It turns out I eliminated this class in the port, there was an easier way to do this
class Ticker implements Runnable {...}

接下来,将其移植到 Android

除了为构建此应用而强制创建新的 Android Eclipse 应用程序外,要解决的第一个问题是将 UI 和图形 API 从 java.awt.* 替换为等效的 Android API。以经典的“(1)我将告诉您我即将告诉您的内容...和(2)我将告诉您...”的方式,这是所有更改后此 Android 应用程序的最终新类图(再次,不要纠结于 UML 的纯粹性)。

New UML

我将接下来在不同级别解释这些。

应用截图

绘制球体且用户可以移动球体的主活动

Main play screen

菜单

Main options

“添加球体”自定义活动

Game options

带有新活动和意图的自定义对话框

Android 支持有限的对话框集。我选择使用新的 Activity Intent 创建自定义屏幕。当选择“Options...”菜单项时,焦点会从主活动转移到 GameOptions 活动。将调用 ImpactPhysics.java 中的以下代码。在此用法中,我们创建了一个指向 GameOptionsIntent。然后创建一个 Bundle,其中将包含 gameParams 对象的序列化实例。然后通过 startActivityForResult(myIntent,STATIC_OPTIONS_VALUE); 启动活动。

    public boolean onOptionsItemSelected(MenuItem item) 
    {
        // Handle item selection 
...
        else if (item.getItemId() == OPTION_MENUID)	//user clicked Options...
            options();
...
           return super.onOptionsItemSelected(item);    
    }
    
    /**
     * invoke the 2nd activity which contains the main program options
     */
    void options()
    {
        Intent myIntent = new Intent(this, GameOptions.class);

        Bundle b = new Bundle();
        b.putSerializable("options", gameParams);
        myIntent.putExtras(b);
        startActivityForResult(myIntent,STATIC_OPTIONS_VALUE); //this will start 
			//the activity and tell framework to expect a response
    }

新活动将获得用户焦点...

Options

...用户将与小部件交互,更改参数,并在返回时调用 onActivityResult 以从 GameOptions 活动检索响应。结果将用于更新当前选项。

// In GameOptions when the "Apply and quit" button is pressed, 
// a new Intent and Bundle are created
// and the updated gameParams are serialized in the bundle and returned setResult
View.OnClickListener quitHandler = new View.OnClickListener() {
    public void onClick(View v) {
        // read widget values
        ...
            
        // prepare to send results back to invoker
        Intent resultIntent = new Intent();
        Bundle b = new Bundle();
        b.putSerializable("options", gameParams);
        resultIntent.putExtras(b);
        setResult(Activity.RESULT_OK, resultIntent);
        finish();
    }  //onClick

// Back in ImpactPhysics
public void onActivityResult(int requestCode, int resultCode, Intent data) 
{     
    super.onActivityResult(requestCode, resultCode, data); 
    switch(requestCode) 
    { 
        case (STATIC_OPTIONS_VALUE) : 
        { 
          if (resultCode == Activity.RESULT_OK) 
          { 
              //retrieve intended options
              Bundle b = data.getExtras();
              gameParams = (GameOptionParams) b.getSerializable("options");
              ...

移除 Ticker

原始小程序使用一个单独的线程来提供“ticker”。这是一种提供周期性时间参考的有效方法,但我认为它不是必需的。在新的 AnimatorView 类中,当活动可见时,会从 ImpactPhysics OnResume 调用 OnResumeProxy(参见 活动生命周期)。

其中有一个 TimerTask 的匿名方法,它将定期触发以使视图失效,这将反过来导致 onDraw 被调用,从而根据当前动态重新绘制所有球体的新位置。

activityLifecycle

public void OnResumeProxy()
{
    mTmr = new Timer(); 
    mTsk = new TimerTask() 
    {
        //anonymous method  
        public void run() 
        {
            ...
            //redraw ball. Must run in background thread to prevent thread lock.
            RedrawHandler.post(new Runnable() 
            {
                public void run() 
                {
                    invalidate();
                }
            });
        }
    }; // TimerTask

    //task, long delay, long period
    mTmr.schedule(mTsk,speed, speed); //start timer
} //OnResumeProxy

画布仍然使用,但方式略有不同

在整体重构到位后,事实证明,从 AWT 到 Android 的绘图基元(至少是 android.graphics.Canvas.drawCircle API)足够相似,可以轻松移植。当屏幕(或可能画布)失效并因此需要重新绘制时,会调用 AnimatorView.onDraw 。用于绘制球体的 drawCircle 在我的用法中接受 4 个参数:X、Y、半径和 Paint (画笔)。

protected void onDraw(Canvas canvas) 
{
    super.onDraw(canvas);
    ...
    for (int i=0; i< currCount; i++ )
    {
        canvas.drawCircle(ballArray[i].x, ballArray[i].y, 
	ballArray[i].radius, ballArray[i].mPaint);
    }
} //onDraw

为了展示真实对话框的工作原理,添加了颜色选择器对话框

我查看了 Android 开源项目中的 ColorPickerDialog 类(public class ColorPickerDialog extends Dialog),它扩展了 android.app.Dialog ,所以我想玩一玩。我修改它以将颜色圆轮缩放到屏幕大小。ImpactPhysics 中的以下代码展示了如何启动此对话框。请注意,我在当前版本中并未实际使用此代码。我只是简单地创建具有随机颜色的随机大小的球。此代码的目的是演示如何扩展对话框。

Options

/**
 * Menu action item to open ColorPickerDialog to choose new color for added balls
 */
void chooseColor()
{
    showDialog(COLOR_DIALOG_ID);	//step 1, call framework to load/show this dialog 
				// (in step 2)
} 
    
//called by framework as a result of showDialog(COLOR_DIALOG_ID);
protected Dialog onCreateDialog(int id)
{
    switch (id) 
    {
        case COLOR_DIALOG_ID:
            return new ColorPickerDialog(this, this, Color.RED); //step 2, 
					// create and show the dialog
    }
    return null;
}

菜单

在活动底部添加菜单非常容易。基本上,这是一个两步过程。首先,您覆盖 onCreateOptionsMenu,它在您的应用程序中只调用一次。请注意,只有 6 个菜单项的空间。如果您需要超过 6 个,那么框架会创建一个“更多”项,然后显示更多项目供选择。其次,您覆盖 onOptionsItemSelected,它会随您选择的 MenuItem 一起调用。

//step 1, create the menus (1 time call)
public boolean onCreateOptionsMenu(Menu menu) 
{
    menu.add(Menu.NONE,EXIT_MENUID,Menu.NONE,"Exit");
    menu.add(Menu.NONE,CLEAR_MENUID,Menu.NONE,"Clear");
    menu.add(Menu.NONE,ADD_MENUID,Menu.NONE,"Add balls");
    menu.add(Menu.NONE,OPTION_MENUID,Menu.NONE,"Options...");
    menu.add(Menu.NONE,ABOUT_MENUID,Menu.NONE,"About...");
    //the following menu items get lumped into the "More" category
    menu.add(Menu.NONE,POP_MENUID,Menu.NONE,"Pop");
    menu.add(Menu.NONE,COLOR_MENUID,Menu.NONE,"Choose color");
    return super.onCreateOptionsMenu(menu);
}
    
//step 2, callback when menu button pressed
public boolean onOptionsItemSelected(MenuItem item) 
{
    // Handle item selection 
    if (item.getItemId() == EXIT_MENUID)		//user clicked Exit
        finish(); //will exit the activity
    else if (item.getItemId() == CLEAR_MENUID)	//user clicked Clear
        clearScreen();
    else if (item.getItemId() == ADD_MENUID)		//user clicked Add balls
        addBallChoice();
    else if (item.getItemId() == COLOR_MENUID)	//user clicked choose color
        chooseColor();
    else if (item.getItemId() == OPTION_MENUID)	//user clicked Options...
        options();
    else if (item.getItemId() == POP_MENUID)		//user clicked Pop
        pop();
    else if (item.getItemId() == ABOUT_MENUID)	//user clicked About...
        aboutBox();		
    return super.onOptionsItemSelected(item);    
}

添加原始版本中没有的新功能

现在应用程序或多或少地像原始版本一样运行,接下来是使用原生 Android API 添加功能。

音效

要播放声音,您可以使用 android.media.MediaPlayer 类。GameOptions 活动中的一个选项是以循环方式播放气泡声。假设您有一个外部媒体文件,格式为 MP3 或 WAV(以及其他),您将其放置在项目的 /res/raw 文件夹中

Options

然后,您创建 MediaPlayer 对象,在这种情况下,我希望启用循环。

mediaPlayerBubbles = MediaPlayer.create(this, R.raw.bubbles); //note the R.raw.bubbles 
						//refers to /res/raw/bubbles.wav
mediaPlayerBubbles.setLooping(true);   

然后,当您想开始/停止声音时,这样做(我在从自定义活动返回时这样做)。

if (gameParams.bubbleSound == true)
{
      float level = (float)gameParams.volumeBarPosition/100;
      mediaPlayerBubbles.setVolume(level,level);  	//range 0.0 to 1.0f
      if (mediaPlayerBubbles.isPlaying() == false)
      {
          mediaPlayerBubbles.start(); 		// no need to call prepare(); 
						// create() does that for you
      }
}
else
{
      if (mediaPlayerBubbles.isPlaying() == true)
      {
             mediaPlayerBubbles.pause();
      }
}

加速度计输入,摇一摇!

此应用程序读取加速度计输入(仅 x 和 y,不包括 z)并将其应用于所有球体的重力。我还想添加一个“摇动”检测功能,如果您通过摇动手机施加一定水平的加速度,它将采取一些行动。我曾假设 Android 传感器 API 会提供此功能,但似乎并非如此。然而,好消息是,编写此代码相当容易。以下代码 onSensorChanged 在加速度计状态改变时从主活动调用。此代码执行几件事。它应用了一些低通滤波,以便事件不会发生得太快。它只会每 100 毫秒才可能采取行动。如果样本之间的速度差大于 SHAKE_THRESHOLD,则它只是设置一个标志来表示这种情况。动画代码 mBallView.shakeItUp() 反过来将对每个球施加一个临时的负斥力,这将导致它们相互远离。然后此代码播放一个“sprong”声音以非循环方式,即一次性,表示此情况。

public void onSensorChanged(SensorEvent event) {
    long curTime = System.currentTimeMillis();
    boolean shakeDetected = false;
    // only allow one update every 100ms.
    if ((curTime - lastUpdate) > 100) 
    {
        long diffTime = (curTime - lastUpdate);
        lastUpdate = curTime;
     
        x = event.values[0];
        y = event.values[1];
        z = event.values[2];
     
        float speed = Math.abs(x+y+z - last_x - last_y - last_z) / diffTime * 10000;
        if (speed > SHAKE_THRESHOLD) 
        {
            // yes, this is a shake action! Do something about it!
            shakeDetected = true;
        }
        last_x = x;
        last_y = y;
        last_z = z;
    }
        
    if (gameParams.shakeItUp && shakeDetected == true)
    {
        if (mBallView.bShakeItUpInPlay==false)
        {
            mBallView.shakeItUp();
            float level = (float)gameParams.volumeBarPosition/100;
            mediaPlayerSprong.setVolume(level,level);  //range 0.0 to 1.0f
            mediaPlayerSprong.start();
        }
    }
    mBallView.updateXYgravity(event.values[0], event.values[1]);
}	//onSensorChanged

Notifications

您可能会看到许多应用程序将通知发布到屏幕顶部的通知状态栏,或者弹出通知。我没有发现这有任何强大的用途,但还是编码了它,只是为了学习如何做。请注意,此处提供的实际代码未启用 Toast 调用,这有点烦人,但我只是想展示它是如何完成的。AnimatorView addRandomBall 展示了如何通过 Toast API 显示弹出通知。它还调用 showStatus() 以在状态栏中显示通知。

    public void addRandomBall()
    {
...
        //for fun, add toast message
        Context context = getContext();
        CharSequence text = "Number of balls = " + currCount;
        Toast toast = Toast.makeText(context, text, Toast.LENGTH_SHORT);
        toast.show();  
        
        //for fun lets try the status bar notification
        if (currCount > 100) showStatus();
    }	//addRandomBall
    
    void showStatus()
    {
        //get ref to NotificationManager
        String ns = Context.NOTIFICATION_SERVICE;
        NotificationManager mNotificationManager = 
			(NotificationManager) pContext.getSystemService(ns);
        
        //instantiate it
        int icon = R.drawable.notification_icon;
        CharSequence tickerText = "balls = "+currCount;
        long when = System.currentTimeMillis();

        Notification notification = new Notification(icon, tickerText, when);
        
        //Define the notification's message and PendingIntent: 
        Context context = pContext.getApplicationContext();
        CharSequence contentTitle = "ImpactPhysics";
        CharSequence contentText = "Ball status";
        Intent notificationIntent = new Intent();
        PendingIntent contentIntent = PendingIntent.getActivity
					(pContext, 0, notificationIntent, 0);

        notification.setLatestEventInfo
		(context, contentTitle, contentText, contentIntent);
        
        //Pass the Notification to the NotificationManager: 
        mNotificationManager.notify(HELLO_ID, notification);
    }

这是通过 Toast API 显示的弹出消息的样子

Options

这是通知栏的样子

Options

以及当您显示通知时(通过向下滑动栏)实际通知是什么样子

Options

WebView 用于显示“关于...”

对于“关于...”菜单项,我使用了 3rd 活动中的 WebView 来加载 /res/assets/about.html 中的内部 HTML 文件 mWebView.loadUrl(file:///android_asset/about.html)。

void aboutBox()
{
    Intent myIntent = new Intent(this, WebViewHelp.class);
    startActivity(myIntent); //step 1, start the WebView activity
} 
    
//The WebView
public class WebViewHelp extends Activity 
{
    WebView mWebView;

    // Called when the activity is first created.
    @Override
    public void onCreate(Bundle savedInstanceState) 
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.webview);
            
        mWebView = (WebView) findViewById(R.id.webviewhelp);
        if (mWebView == null)
        {
            System.out.println("Could not load webviewhelp");
        }
        else
        {
            mWebView.getSettings().setJavaScriptEnabled(true);
            mWebView.loadUrl("file:///android_asset/about.html"); //this loads 
				//the browser and view the file
        }
    }	//onCreate        
}	//WebViewHelp  

然后显示此文件,并且所有超链接都可以用于导航到锚点和外部 URL。使用标准页面滚动来滚动文件的多个页面。

Options

捏拉缩放

传感器检测代码实现了熟悉的“捏拉缩放”双指手势。我曾假设 Android 传感器 API 会为这种“捏拉缩放”提供事件或回调,但似乎并非如此(除了可能针对 3 个专用视图类)。然而,好消息是,自己实现“捏拉缩放”相当容易。在 AnimatorView 中,所有捏拉缩放代码都在 onTouch 处理程序中。事件 MotionEvent.ACTION_DOWN 在第一根手指触摸时发生,MotionEvent.ACTION_POINTER_DOWN 在第二根手指触摸时发生,并在此代码中用于指示捏拉或缩放的开始。“捏拉”是指 MotionEvent.ACTION_MOVE 事件发生,然后两根手指之间的间距变小,反之,如果间距变大,则是“缩放”。

enum PinchActions {
    /**
     * user not touching the screen
     */
    NONE
    /**
     * Pinch or Zoom action in progress , 2 fingers in play
     */
    ,ZOOM

    /**
     * single finger drag in progress
     */
    ,DRAG
}
    
public boolean onTouch(android.view.View v, android.view.MotionEvent e) 
{       
    boolean consumed = true;	//assume we consumed this event
    float x = e.getX();
    float y = e.getY(); 
        
    switch (e.getAction() & MotionEvent.ACTION_MASK)
    {
        case MotionEvent.ACTION_DOWN:
            //ignore
            pinchMode = PinchActions.DRAG;
            currentBall = nearestBall(x,y,currCount);
            mx = x; my = y;
            break;
            
        case MotionEvent.ACTION_UP:
            // this magic number means that the mouse is up
            pinchMode = PinchActions.NONE;
            break;
            
        case MotionEvent.ACTION_POINTER_DOWN:
            //this action occurs when 2nd finger is down
            oldDist = spacing(e);
            if (oldDist > 10f) {
                pinchMode = PinchActions.ZOOM;
            }
            break;
            
        case MotionEvent.ACTION_POINTER_UP:
            //I think this means the 2nd finger was raised
            pinchMode = PinchActions.DRAG;
            break;

        case MotionEvent.ACTION_MOVE:
            if (pinchMode == PinchActions.ZOOM) 
            {
                //these moves happen frequently, apply a kind of low pass filter 
	       //to slow it down
                newDist = spacing(e);
                if (newDist > 10f) {
                    float scale = newDist / oldDist;
                    reScaleBalls(scale);
                }
            }
            else if (pinchMode == PinchActions.DRAG)
            {
                mx = x; my = y;
            }
            break;
            
        default:
            consumed = false;
            break;
        }

        //return True if the listener has consumed the event, false otherwise.
        return consumed;
}	//onTouch

当前代码的局限性和未来的改进

这段代码是我第一次尝试 Android,因此我假设专业的 Android 开发人员会发现错误和改进空间。请就您认为可以改进的方面提供反馈!

持久存储

下一个版本我想在选项菜单中添加持久存储。

解析 options.xml

我不喜欢这段代码的另一个方面是,虽然选项活动的 XML 布局(/res/layout/options.xml)在 GameOptions 活动中运行良好,但我找不到一种简单的方法从主 ImpactPhysics 活动中提取小部件的属性,除非手动解析 XML 文件(不,谢谢)。我怀疑有一种简单的方法可以做到这一点,但对我来说并不明显。在下一个版本中,我希望在启动时从 options.xml 文件中读取默认值。

一些奇怪的错误

我确实遇到了一个奇怪的 bug。我有两个媒体文件,它们与其他文件来自同一个网站,但在模拟器和真实手机之间表现出糟糕且不同的行为。文件 pop.mp3 在我的手机上无法工作(MediaPlayer.create(this, R.raw.pop) 返回 null ),但在模拟器上可以工作。然后,在另一种故障模式下,pop.wav 不会崩溃,但在手机上没有声音,但在模拟器上却有。我一直没有弄清楚这是怎么回事。但那是我遇到的唯一异常。

历史

  • 版本 1.0,2011 年 10 月
© . All rights reserved.