讀古今文學網 > Android程序設計:第2版 > 控制器佈局 >

控制器佈局

P167「組裝圖形界面」一節演示了一個包含兩個按鈕的視圖。雖然按鈕看起來很不錯,在單擊時甚至高亮顯示,但是這些按鈕沒有什麼用。單擊它們並不會執行任何操作。P165「控制器」一節已經介紹了Android框架如何把外部動作(如屏幕觸摸、按鍵等)翻譯成事件,並把這些事件插入到隊列中,然後傳遞給應用。例6-4說明了如何把事件處理器添加到demo中的一個按鈕中,使得當單擊按鈕時可以執行某些操作。

例6-4:綁定按鈕


@Override public void onCreate(Bundle state) {
    super.onCreate(state);
    setContentView(R.layout.main);
    final EditText tb1 = (EditText) findViewById(R.id.text1);
    final EditText tb2 = (EditText) findViewById(R.id.text2);
    ((Button) findViewById(R.id.button2)).setOnClickListener(
        new Button.OnClickListener {
            // mRand is a class data member
            @Override public void onClick(View arg0) {
                tb1.setText(String.valueOf(mRand.nextInt(200)));
                tb2.setText(String.valueOf(mRand.nextInt(200)));
            }
        }
    );
}
  

這個版本的應用在運行時還是很像圖6-2所示。但是,和之前不同,在這個版本中,每當用戶單擊Green按鈕時,在EditText文本框中的數字就會發生變化,如圖6-4所示。

圖6-4:可以工作的按鈕

雖然改變數字看起來沒什麼意思,但是這個小小的例子說明了應用響應UI事件的標準機制。要注意的是,暫不討論外觀,該例子沒有破壞MVC的分離規則!在實現OnClickListener時,為了響應setText調用,EditText對像更新文本的內部展現,然後調用自己的invalidate方法。它並沒有馬上渲染屏幕。在編程中,很少有規則是絕對的。模型-視圖-控制器之間的界限實際上相當接近。

在這個例子中,Button類的實例通過回調執行操作,正如P98「重寫(override)和回調」一節所描述的。Button是視圖View的子類,它定義了接口OnClickListener和方法setOnClickListener,通過它們來註冊Listener。OnClickListener接口只定義一個方法onClick。當一個按鈕從UI框架中接收到事件後,除了其他要執行的操作,它會檢查事件,看是否滿足「單擊」的條件。(在給出的第一個例子中,當單擊時,按鈕會高亮顯示,甚至是在添加監聽器之前。)如果事件確實滿足「單擊」的要求,並且單擊listener已經安裝,就會調用該listener的onClick方法。

單擊監定器可以自由地實現任何需要的自定義行為。在這個例子中,自定義行為會創建兩個0~200之間的隨機數,並分別把這兩個隨機數放到文本框中。要擴展Button的行為,所要做的不是實現Button的子類並覆蓋其事件處理方法,而是註冊一個實現了該行為的單擊監聽器。這當然就簡單了很多!

單擊處理程序特別值得關注,因為在Android系統的核心(Android框架事件隊列)中並不存在單擊事件!相反,View事件處理合成了其他的事件「單擊」概念。如果設備包含觸摸屏,則屏幕觸摸就被認為是單擊。如果設備在其D-pad中包含中心鍵,或Enter鍵,則按下和釋放這些鍵都會註冊單擊事件。View客戶端不需要考慮什麼是單擊,或者它在某個設備上是如何生成的。它們只處理較高抽像層次的概念,細節留給了Android框架來處理。

一個視圖只能有一個onClickListener。在一個視圖上第二次調用setOnClickListener會首先刪除老的監聽器,然後再新安裝一個監聽器。另一方面,一個監聽器可以監聽多個視圖。例如,例6-5所示的代碼是另一個應用的一部分,它看起來和例6-2完全一樣。但是,在這個版本中,按下任何一個按鈕都會更新文本框。

這個功能對於包含一些生成相同行為的動作的應用是非常方便的。但是,不要嘗試創建可以在所有的部件中使用的強大的單個監聽器!如果你的代碼包含多個較小的監聽器,每個實現一個簡單的行為,那麼代碼維護會簡單得多。

例6-5:監聽多個按鈕


@Override public void onCreate(Bundle state) {
    super.onCreate(state);
    setContentView(R.layout.main);
    final EditText tb1 = (EditText) findViewById(R.id.text1);
    final EditText tb2 = (EditText) findViewById(R.id.text2);
    Button.OnClickListener listener = new Button.OnClickListener {
        @Override public void onClick(View arg0) {
            tb1.setText(String.valueOf(rand.nextInt(200)));
            tb2.setText(String.valueOf(rand.nextInt(200)));
        } };
    ((Button) findViewById(R.id.button1)).setOnClickListener(listener);
    ((Button) findViewById(R.id.button2)).setOnClickListener(listener);
}
  

監聽模型

Android UI框架普遍使用處理程序安裝模式。雖然前面給出的例子都是Button視圖,但很多其他的Android部件可以定義監聽器。View類定義了一些到處可用的事件和監聽器,我們將在後面進一步詳細探討這些。但是,其他類定義了其他專門的事件類型,提供只對這些類有意義的部件處理這些事件。標準的方式是允許客戶端自定義部件行為,而不需要繼承它。

這種模式也是程序處理外部的且是異步的操作的很好的方式。是否響應遠程服務器的狀態變化或基於位置的服務更新,你的應用都可以定義自己的事件和監聽器以只回應客戶端的請求。

以上給出的例子是基礎的簡化版本。雖然這些模式說明了如何連接View和Controller,但是它們都沒有真正的模型(例6-4中使用的String實際上是在EditText模型的實現中的)。

後面會更詳細地介紹如何構建一個真正的、可用的模型。例6-6給出的兩個類組成的模型支持對演示應用的擴展。這些擴展提供了存儲對像列表的工具,每個擴展都包含x和y坐標、顏色和尺寸。它們還提供註冊監聽器的方式,以及該監聽器必須實現的接口。這些例子都是基於通用的監聽器模型的,因此它們是相當簡單的。

例6-6:Dots模型


package com.oreilly.android.intro.model;
/** A dot: the coordinates, color and size. */
public final class Dot {
    private final float x, y;
    private final int color;
    private final int diameter;
    /**
     * @param x horizontal coordinate.
     * @param y vertical coordinate.
     * @param color the color.
     * @param diameter dot diameter.
     */
    public Dot(float x, float y, int color, int diameter) {
        this.x = x;
        this.y = y;
        this.color = color;
        this.diameter = diameter;
    }
    /** @return the horizontal coordinate. */
    public float getX { return x; }
    /** @return the vertical coordinate. */
    public float getY { return y; }
    /** @return the color. */
    public int getColor { return color; }
    /** @return the dot diameter. */
    public int getDiameter { return diameter; }
}
package com.oreilly.android.intro.model;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
/** A list of dots. */
public class Dots {
    /** DotChangeListener. */
    public interface DotsChangeListener {
        /** @param dots the dots that changed. */
        void onDotsChange(Dots dots);
    }
    private final LinkedList<Dot> dots = new LinkedList<Dot>;
    private final List<Dot> safeDots = Collections.unmodifiableList(dots);
    private DotsChangeListener dotsChangeListener;
    /** @param l the new change listener. */
    public void setDotsChangeListener(DotsChangeListener l) {
        dotsChangeListener = l;
    }
    /** @return the most recently added dot, or null. */
    public Dot getLastDot {
        return (dots.size <= 0) ? null : dots.getLast;
    }
    /** @return the list of dots. */
    public List<Dot> getDots { return safeDots; }
    /**
     * @param x dot horizontal coordinate.
     * @param y dot vertical coordinate.
     * @param color dot color.
     * @param diameter dot size.
     */
    public void addDot(float x, float y, int color, int diameter) {
        dots.add(new Dot(x, y, color, diameter));
        notifyListener;
    }
    /** Delete all the dots. */
    public void clearDots {
        dots.clear;
        notifyListener;
    }
    private void notifyListener {
        if (null != dotsChangeListener) {
            dotsChangeListener.onDotsChange(this);
        }
    }
}
  

前面主要介紹了如何使用這個模型,在下一個實例中要介紹的是一個可以用來查看模型的widget庫,即DotView。DotView旨在以正確的顏色、在正確的位置描繪出模型中的點。這個應用的完整源代碼在本書的Web站點上,可以免費獲取。

例6-7是增加了新的模型和視圖後的新演示應用。

例6-7:Dots演示


package com.oreilly.android.intro;
import java.util.Random;
import android.app.Activity;
import android.graphics.Color;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import com.oreilly.android.intro.model.Dot;
import com.oreilly.android.intro.model.Dots;
import com.oreilly.android.intro.view.DotView;
/** Android UI demo program */
public class TouchMe extends Activity {
    public static final int DOT_DIAMETER = 6;
    private final Random rand = new Random;
    final Dots dotModel = new Dots;
    DotView dotView;
    /** Called when the activity is first created. */
    @Override public void onCreate(Bundle state) {
        super.onCreate(state);
        dotView = new DotView(this, dotModel);
        // install the View
        setContentView(R.layout.main);
        ((LinearLayout) findViewById(R.id.root)).addView(dotView, 0);
1
        // wire up the Controller
        ((Button) findViewById(R.id.button1)).setOnClickListener(
            new Button.OnClickListener {
2
                @Override public void onClick(View v) {
                    makeDot(dots, dotView, Color.RED);
3
                } });
        ((Button) findViewById(R.id.button2)).setOnClickListener(
            new Button.OnClickListener {
2
                @Override public void onClick(View v) {
                    makeDot(dots, dotView, Color.GREEN);
3
                } });
        final EditText tb1 = (EditText) findViewById(R.id.text1);
        final EditText tb2 = (EditText) findViewById(R.id.text2);
        dots.setDotsChangeListener(new Dots.DotsChangeListener {
4
            @Override public void onDotsChange(Dots d) {
                Dot d = dots.getLastDot;
                tb1.setText((null == d) ? "" : String.valueOf(d.getX));
                tb2.setText((null == d) ? "" : String.valueOf(d.getY));
                dotView.invalidate;
            } });
    }
    /**
     * @param dots the dots we're drawing
     * @param view the view in which we're drawing dots
     * @param color the color of the dot
     */
    void makeDot(Dots dots, DotView view, int color) {
5
        int pad = (DOT_DIAMETER + 2) * 2;
        dots.addDot(
            DOT_DIAMETER + (rand.nextFloat * (view.getWidth - pad)),
            DOT_DIAMETER + (rand.nextFloat * (view.getHeight - pad)),
            color,
            DOT_DIAMETER);
    }
}
  

以下是對代碼的一些說明:

1 把新的DotView添加到XML定義的佈局的上方。

2 把onClickListener回調添加到Red和Green按鈕。這些事件handler和前面例子中的handler的區別在於它們是通過proxy(代理)掛接到本地方法makeDot的。這個新的方法生成了一個點(第5項)。

3 onClick內會調用makeDot(當單擊按鈕時執行這個動作)。

4 這個例子中的最大變化是Model是連接到View的方式,使用回調安裝了一個dotsChangeListener。當模型發生變化時,會調用新的監聽器。這個監聽器在最左邊和最右邊的文本框中分別安裝了x和y坐標,並請求DotView撤銷自己(調用invalidate)。

5 這是makeDot的定義。這個新方法創建一個點,確保它在DotView的邊界範圍內,並把它加入到模型中。它還支持通過參數指定點的顏色。

圖6-5顯示了應用在運行時的樣子。

圖6-5:運行Dots演示程序

單擊Red按鈕會給DotView增加一個新的紅點。單擊Green按鈕增加一個綠點。文本框中給出的是最後增加的點的坐標。

不難看出,它是在例6-2的基礎結構上增加了一些擴展。例如,以下是單擊Green按鈕後的事件序列:

1.單擊按鈕,調用onClickHandler方法。

2.調用makeDot,參數包含顏色Color.GREEN。makeDot方法生成隨機坐標,並在模型的這個坐標處增加一個新的綠點。

3.當模型更新時,會調用onDotsChangeListener。

4.監聽器更新文本視圖中的值,並請求重繪DotView。

監聽觸摸事件

可能你已經猜到,在演示應用中增加對觸摸事件的處理實際就是增加tab handler。例6-8所示的代碼對應用進行了擴展,在DotView中對應於屏幕上被觸摸的點處放置了一個青色點。這段代碼應該加到演示應用(例6-7)的onCreate函數的開始位置並在其父方法之後。需要注意的是,由於該代碼顯示最近添加點的x和y坐標只是連接到模型上,無論View如何添加點,它都可以正常工作。

例6-8:觸摸點


dotView.setOnTouchListener(new View.OnTouchListener {
    @Override public boolean onTouch(View v, MotionEvent event) {
        if (MotionEvent.ACTION_DOWN != event.getAction) {
            return false;
        }
        dots.addDot(event.getX, event.getY, Color.CYAN, DOT_DIAMETER);
        return true;
    } });
  

傳遞給handler的MotionEvent除了包含觸摸位置這個屬性之外,還包含一些其他屬性。如例子所示,它還包含事件類型,包括DOWN、UP、MOVE或CANCEL。一個簡單的觸摸事件實際上會生成一個DOWN事件和一個UP事件。觸摸並拖曳會生成DOWN事件和一系列MOVE事件以及最後的UP事件。

MotionEvent提供的手勢處理工具很有意思。該事件包含觸摸點的大小及壓力。這意味著對於支持MotionEvent的設備,應用可以區分是一個手指的觸摸還是兩個手指的觸摸,是很輕的觸摸還是很重的觸摸。

效率在移動世界裡還是相當重要的。UI框架在跟蹤和報告觸摸屏事件時面臨著兩難境地。報告的事件太少可能會造成準確性不足,而難以跟蹤所執行的操作,如手寫識別。另一方面,報告的觸摸抽樣點太多,每個都是一個事件,會給系統造成很重的負擔。Android UI框架對這個問題的解決方案是把幾組樣本關聯起來,從而減少負載的同時依然能夠保證準確性。要查看和某個事件關聯的所有樣本,可以使用包含getHistoricalX、getHistoricalY等方法的歷史工具。

例6-9演示了如何使用歷史工具。它擴展了演示應用,當用戶觸摸屏幕時,可以跟蹤用戶的手勢。框架把抽樣點的x和y坐標傳遞給對象的onTouch方法,安裝為DotView的OnTouchListener。每個樣本點會對應繪出一個青色點。

例6-9:跟蹤動作


private static final class TrackingTouchListener
    implements View.OnTouchListener
{
    private final Dots mDots;
    TrackingTouchListener(Dots dots) { mDots = dots; }
    @Override public boolean onTouch(View v, MotionEvent evt) {
        switch (evt.getAction) {
            case MotionEvent.ACTION_DOWN:
             break;
          case MotionEvent.ACTION_MOVE:
            for (int i = 0, n = evt.getHistorySize; i < n; i++) {
                addDot(
                    mDots,
                    evt.getHistoricalX(i),
                    evt.getHistoricalY(i),
                    evt.getHistoricalPressure(i),
                    evt.getHistoricalSize(i));
            }
            break;
        default:
            return false;
        }
        addDot(
            mDots,
            evt.getX,
            evt.getY,
            evt.getPressure,
            evt.getSize);
        return true;
    }
    private void addDot(Dots dots, float x, float y, float p, float s) {
        dots.addDot(
            x,
            y,
            Color.CYAN,
            (int) ((p * s * Dot.DIAMETER) + 1));
    }
}
  

圖6-6顯示了擴展版本的應用在單擊並拖曳幾下後的可能樣子。

在這個實現中,根據抽樣點的尺寸和壓力來確定所要繪製的點的直徑。遺憾的是,Android模擬器並不會模擬觸摸壓力和尺寸,因此所有點直徑相同。尺寸和壓力值在不同設備之間會被范化成0.0~1.0之間的浮點值。然而,這兩個值依賴於屏幕的實際精度,它們都有可能超過1.0。模擬器報告事件壓力和尺寸只有最小值,即0。

圖6-6:演示應用持續運行較長時間

ACTION_MOVE事件的循環處理程序批量處理歷史事件。當觸摸樣本變化速度快於框架的傳遞速度時,Android框架會把它們綁定成一個事件。MotionEvent事件的getHistorySize方法返回樣本點的數量,而各種getHistory方法可以獲取後續事件的各個參數。

當軌跡球(trackball)移動時,包含軌跡球的設備也會生成MotionEvent事件。這些事件和觸摸屏的觸摸動作對應的事件類似,但是處理方式不同。軌跡球的MotionEvent是通過dispatchTrackballEvent傳遞給View的,而觸摸屏的事件使用的是dispatchTouchEvent,傳遞的是觸摸動作。雖然dispatchTrackballEvent確實把事件傳遞給了onTrackballEvent,但它並不會預先把事件傳遞給監聽器!不但軌跡球生成的MotionEvent在普通的觸摸機上不可見,而且為了對MotionEvent進行響應,必須有個widget繼承View類,覆蓋onTrackballEvent方法。

軌跡球生成的MotionEvent是通過另一種方式處理的。如果沒有使用它們(很快會對其進行定義),它們就會轉化成D-pad按鍵事件。如果大部分設備或者配備了D-pad或者配備了軌跡球,而不是兩種設備都配備,那這種處理就是有意義的。如果沒有這個轉換,就不可能在只包含軌跡球的設備上生成D-pad事件。當然,這也說明應用處理軌跡球事件時必須謹慎,因為它有可能會破壞轉換。

轉換後,軌跡球運動作為一系列D-pad按鍵,應用可見。

多個指針和手勢

很多設備支持同時跟蹤多個指針,該功能有時稱為「多點觸控技術(multitouch)」。當用戶觸摸屏幕上的不同地方,會分別獨立對這些觸摸跟蹤。這些跟蹤可用於判別具有特殊含義的複雜手勢,比如滾動、縮放、翻頁等。

之前介紹的所有事件方法,返回關於MOVE事件的信息(getX、getY、getHistoricalX、getHistoricalY等)通過額外參數支持多點觸控,該參數指定調用所指向的特定軌跡。舉個例子,除getX函數外,還有個函數是getX(int pointerIndex)。參數pointerIndex支持調用方訪問各種不同軌跡,方法getPointerCount返回事件中記錄的不同軌跡數。不幸的是,事件中各個軌跡的索引並不是常量。換句話說,如果用戶通過拇指觸摸屏幕,食指觸摸索引,拇指的軌跡可能會以連續模式顯示,首先在索引位置0,然後在索引位置1,然後又回到索引位置0。為了通過幾個事件追蹤單個軌跡,需要使用軌跡ID,而不是索引。為了完成這一點,使用方法getPointerId和findPointerIndex,對ID和索引進行轉換。

例6-10從前面的例子中擴展了onTouch方法,能夠追蹤多個軌跡。

例6-10:追蹤運動

在這段代碼中有幾點需要注意。首先,注意case語句不是基於事件動作而是基於該動作執行了掩碼操作後的版本進行switch判斷。這種基於位的觸發操作顯然有些陳舊。有必要使得回調可以向後兼容。要忽略多個軌跡,不要執行掩碼操作,直接基於事件動作進行switch判斷,而且在switch判斷中要處理default情況。

其次,注意switch中新增了兩種情況:MotionEvent.ACTION_POINTER_DOWN和MotionEvent.ACTION_POINTER_UP。在這個簡單的例子中,這兩種情況用於表示新軌跡的起始和結束。由於連續軌跡是通過ID表示的,該代碼在軌跡開始時會添加新的ID,在軌跡結束時刪除該ID。

最後,如果事件中包含歷史信息,所有的軌跡也包含相同的歷史記錄數。

在特殊(但很常見的)情況下,多個軌跡組成特定含義的手勢(可能是手抓式縮放),Android庫會借助手勢識別器的支持給出提示信息。官方文檔承認提供的兩個手勢識別器的實現主要是為了給出建議,而不是完整的解決方案。實際上,這兩個識別器GestureDetector和ScaleGestureDetector,有時會支持一些常見的手勢:單擊、雙擊、長按和短按。

一般來說,使用手勢識別器需要創建一個實例,註冊監聽器,把識別器添加到OnTouch處理器上,並傳遞一個新的事件。當有手勢發生時,會通知監聽器。識別器的行為的詳細信息(包括監聽器類型)專門針對特定的識別器。

監聽按鍵事件

支持跨平台處理鍵盤輸入可能是非常棘手的。有些設備包含的按鍵要比其他設備多得多,例如有些設備輸入字符時需要觸摸3次。這是一個絕佳的例子,說明有些處理要盡可能地留給框架(EditText或其子類)來處理。

要擴展部件的KeyEvent處理,使用View的方法setOnKeyListener來安裝OnKeyListener。對於用戶的每次按鍵,監聽器都會收到多個KeyEvent,每個動作對應如下類型之一:DOWN、UP和MULTIPLE。動作類型DOWN和UP表示一個鍵被先按下後釋放這個操作,與MotionEvent類類似。動作類型MULTIPLE表示長按某個鍵(自動重複)。KeyEvent的方法getRepeatCount可以得到MULTIPLE事件的按鍵次數。

例6-11是一個按鍵處理程序實例。把該處理程序添加到演示程序後,當按下或釋放按鍵時,它會在隨機選定的位置顯示一個點。當按下並釋放空格鍵時,會添加一個洋紅色的點;當按下並釋放Enter鍵時,會添加一個黃點;當按下並釋放其他任何鍵時,會添加一個藍色的點。

例6-11:按鍵處理


dotView.setOnKeyListener(new OnKeyListener {
    @Override public boolean onKey(View v, int keyCode, KeyEvent event) {
        if (KeyEvent.ACTION_UP != event.getAction) {
            int color = Color.BLUE;
            switch (keyCode) {
                case KeyEvent.KEYCODE_SPACE:
                    color = Color.MAGENTA;
                    break;
                case KeyEvent.KEYCODE_ENTER:
                    color = Color.YELLOW;
                    break;
                default: ;
            }
            makeDot(dots, dotView, color);
        }
    return true;
} });
  

處理事件的其他方式

你可能已經注意到,到目前為止,所有介紹過的on...方法(包括onKey)的返回值都是boolean(布爾)類型。這種模式使得監聽器可以把後續事件的處理交給調用者來處理。

當把Controller事件交給widget後,widget中的框架代碼會調度該Controller事件,根據其類型執行對應的方法:onKeyDown、onTouch Event等。這些方法,無論是在View中還是在View的某個子類中,都已經實現了該部件的行為。然而,正如之前所描述的,框架會首先把事件提交給相應的監聽器(onTouchListener、onKeyListener等),如果監聽器存在的話。監聽器的返回值決定了其後續事件是否要分發給View的方法。

如果監聽器返回false,則該事件就被分發到View方法,好像處理程序不存在一樣。如果監聽器返回true,就認為事件已經處理了。View會放棄任何進一步的處理。View方法一直都沒有被調用,它沒有機會處理或響應事件。對於View方法而言,相當於該事件不存在。

因此,事件有3種處理方式:

沒有監聽器

事件分發給View方法以正常執行。通過部件實現覆蓋這些方法。

監聽器存在並返回true

監聽器事件處理完全取代正常的部件事件處理。事件不會分發給View。

監聽器存在並返回false

事件先被監聽器處理,然後再由View處理。事件被監聽器處理完後,就會被分發給View執行正常的處理。

例如,把例6-11的按鍵監聽器添加到EditText widget中,會發生什麼情況呢?因為onKey方法總是返回true,只要該方法返回,框架就會丟棄後續的所有KeyEvent事件。這使得EditText按鍵處理無法看到按鍵事件,因此文本框中就不會有文本。這可能不是期望的行為!

如果onKey方法對於某些按鍵事件返回false,那麼框架會把這些事件分發給widget以執行進一步的處理。EditText會看到這些事件,相關的字符會如期附加到EditText文本框中。例6-12是例6-11的擴展,其除了會給模型增加新的點,還會過濾掉傳遞給虛擬的EditText文本框的字符。它只支持數值,其他類型的字符都會被隱藏起來。

例6-12:擴展的按鍵處理


new OnKeyListener {
    @Override public boolean onKey(View v, int keyCode, KeyEvent event) {
        if (KeyEvent.ACTION_UP != event.getAction) {
            int color = Color.BLUE;
            switch (keyCode) {
                case KeyEvent.KEYCODE_SPACE:
                    color = Color.MAGENTA;
                    break;
                case KeyEvent.KEYCODE_ENTER:
                    color = Color.YELLOW;
                    break;
                default: ;
            }
            makeDot(dotModel, dotView, color);
        }
        return (keyCode < KeyEvent.KEYCODE_0)
            || (keyCode > KeyEvent.KEYCODE_9);
    }
}
  

如果應用需要實現全新的事件處理方式(基於onKeyHandler,無法通過合理的行為擴張和過濾實現自己所需的功能),則需要理解和覆蓋View類的按鍵事件處理。處理的核心過程簡單說就是,通過DispatchKeyEvent方法把事件分發給View。DispatchKeyEvent實現了前面所描述的行為,其先把事件交給onKeyHandler,然後如果處理程序返回false,就把事件交給實現KeyEvent.Callback接口的View方法:onKeyDown、onKeyUp和onKeyMultiple。

高級連接:聚焦和線程化

P179「監聽觸摸事件」一節所描述的,把MotionEvent交給部件,該部件的邊界矩形框包含生成該矩形框的觸摸點。確定哪個部件應該接收KeyEvent不是很容易。要做到這一點,類似大多數其他UI框架,Android UI框架支持選擇,即聚焦(focus)。

為了接收焦點,必須把部件的focusable屬性設置為true。設置的方式有兩種:第一種是使用XML佈局屬性(例6-3中的EditView視圖的facusable屬性設置為false);第二種是使用setFocusable方法,如例6-11的第一行代碼所示。用戶通過D-pad按鍵或觸摸屏幕改變View。

當一個部件在焦點中時,它通常通過某些高亮顯示進行渲染,使用戶感覺到它是當前的操作目標。例如,當EditText部件在焦點中時,它不但高亮顯示,而且還把光標置於文本插入位置。

要接收當View進入或離開焦點時的通知,需要安裝OnFocusChangeListener。例6-13說明了監聽器需要添加焦點相關的特徵到演示應用程序。它會引起隨機放置的黑點只要在焦點狀態下就隨機自動添加到DotView中。

例6-13:處理焦點


dotView.setOnFocusChangeListener(new OnFocusChangeListener {
    @Override public void onFocusChange(View v, boolean hasFocus) {
        if (!hasFocus && (null != dotGenerator)) {
            dotGenerator.done;
            dotGenerator = null;
        }
        else if (hasFocus && (null == dotGenerator)) {
            dotGenerator = new DotGenerator(dots, dotView, Color.BLACK);
            new Thread(dotGenerator).start;
        }
} });
  

該OnFocusChangeListener函數沒有什麼特別之處。當DotView成為焦點時,它會創建DotGenerator並生成一個線程來運行它。當該widget離開焦點時,DotGenerator就會被中止並釋放。新的數據成員dotGenerator(例子中沒有給出其聲明)只有當DotView處於焦點時才是非空的。DotGenerator的實現中有另一個重要的強大工具,我們很快就會說到它。

焦點通過View方法requestFocus被傳遞給特定的widget。當為新的目標widget調用requestFocus時,該請求會通過父節點向上傳遞,直到樹結構的根節點。根節點會記住哪個部件在焦點中,並直接把後續的按鍵事件傳遞給它。

這正是UI框架在響應D-pad按鍵時改變焦點到新的widget的方式。Android UI框架識別出下一個會成為焦點的部件,調用該部件的requestFocus方法。這使得當前獲得焦點的部件會失去焦點,而目標部件會得到焦點。

確定獲取焦點的部件的過程是複雜的。為此,遍歷算法需要執行一些複雜的計算,它可能會依賴於屏幕上的其他部件的位置!

舉個例子,設想一下當按下方向鍵的右鍵時會發生什麼,UI框架會把焦點傳遞給當前焦點所在部件的右側的部件。當查看屏幕時,應該是哪個部件可能是一目瞭然;然而,在視圖樹中卻沒有這麼明顯。在樹結構中,目標部件可能在另一層,隔著幾個分支。定位下一個焦點所處的部件取決於部件在樹結構的另一分支的精確維度。幸運的是,儘管確定下一個部件相當複雜,但Android UI框架通常都能正常工作。

如果Android UI框架給出的部件和期望的不同,還存在4個屬性,可以通過方法設置或者通過XML屬性設置,從而強制執行焦點遍歷行為。這些屬性是nextFocusDown、nextFocusLeft、nextFocusRight和nextFocusUp。將任意一個屬性的引用指向某個部件就能確保觸摸板會沿著相應的方向執行遍歷直到把焦點轉移到這個部件為止。

焦點機制的另一個複雜之處在於對於支持觸摸屏的設備,Android UI框架需要區分觸摸板焦點和觸摸焦點。要理解這一點,回想一下不支持觸摸輸入的屏幕,按下某個按鍵的唯一方式是要對它聚焦,使用方向鍵遍歷,然後使用中心鍵生成單擊。但是,對於支持觸摸的屏幕,不需要對按鈕執行聚焦。

無論當前的焦點位於哪個部件中,都可以移動到這個按鈕上並單擊它。但是,即使對於觸摸屏,還是有必要能夠將焦點定位在接受按鍵的部件上,例如EditText widget,從而確定它為後續按鍵事件的目標。為了正確地處理兩種聚焦,需要查看View處理模式FOCUSABLE_IN_TOUCH_MODE,以及View的isFocusableInTouchMode和isInTouchMode方法。

對於包含多個窗口的應用,在聚焦機制中至少包含一個轉折點。窗口有可能失去焦點而沒有通知當前的部件。思考一下,就會發現這種情況是有可能的。如果失去焦點的窗口被放回到最上方,那麼該窗口中處於焦點下的部件會重新得到焦點,而不需要其他操作。

在將朋友的電話號碼輸入到地址簿的應用中。假設你突然回退到電話應用中,看看她的電話號碼的最後幾位。當你返回地址簿時,如果你還需要再次重新聚焦到文本輸入框上,你會覺得很懊惱。你期望的是還回到上次離開時的狀態。

另一方面,該行為會產生奇怪的副作用。特別是,例6-13中所示的自動點特徵的實現在被其他窗口遮蓋時還能夠給DotView添加點。如果後台任務只在特定的widget可見的情況下運行,則當該部件失去焦點時,或窗口失去焦點,或Activity暫停或中止時,都必須清除該任務。

大多數焦點機制的實現是在ViewGroup類中,在requestFocus和requestChildFocus這樣的方法中。要是需要實現一個全新的焦點機制,需要仔細查看這些方法,並相應地覆蓋這些方法。

下面先不探討焦點這一話題,再回到新增加的自動點特徵的實現上。例6-14顯示了DotGenerator的實現。

例6-14:處理線程


private final class DotGenerator implements Runnable {
    final Dots dots;
    final DotView view;
    final int color;
    private final Handler hdlr = new Handler;
1
    private final Runnable makeDots = new Runnable {
2
        public void run { makeDot(dots, view, color); }
    };
    private volatile boolean done;
    // Runs on the main thread
    DotGenerator(Dots dots, DotView view, int color) {
3
        this.dots = dots;
        this.view = view;
        this.color = color;
    }
    // Runs on the main thread
    public void done { done = true; }
    // Runs on a different thread!
    public void run {
        while (!done) {
            try { Thread.sleep(1000); }
            catch (InterruptedException e) { }
            hdlr.post(makeDots);
4
        }
    }
}
  

以下是這段代碼的一些重點說明:

1 創建一個android.os.Handler對象。

2 創建一個新的線程,運行第4項的makeDot。

3 在主線程上運行DotGenerator。

4 在第1項創建的Handler中運行makeDot。

在這個DotGenerator的簡單實現中,在run代碼塊內直接調用了makeDot。但是,這麼做不安全,除非makeDot是線程安全的——Dots類和DotView類也是。這種方式很容易出錯,而且難以維護。實際上,Android UI框架禁止多個線程訪問View類。運行這個實現會導致應用拋出下面這個運行時異常(RuntimeException):


11-30 02:42:37.471: ERROR/AndroidRuntime(162):
 android.view.ViewRoot$CalledFromWrongThreadException:
 Only the original thread that created a view hierarchy can touch its views.
  

我們在第3章已經介紹過這個問題並給出了解決方式。要取消這個限制,DotGenerator需要在構造函數內創建Handler對象。Handler對像和創建該對象的線程關聯,使得線程可以對傳統的事件隊列安全地進行並發訪問。

因為DotGenerator在構造時創建了一個Handler,該Handler和主線程關聯。現在,DotGenerator可以使用Handler把另一個線程的Runnable對像插入隊列,該Runnable對像調用UI線程的makeDot方法。結果正如你所預見的,Handler所指向的傳統事件隊列即UI框架所使用的隊列。從隊列中刪除makeDot的調用,並分發類似任何其他的UI事件,以合理的順序進行分發。因此,這使得Runnable方法開始運行。makeDot是從主線程調用的,UI還是單線程的。

值得重申的是,以上是通過Android UI框架進行編程的基礎模式。當用戶的操作耗時超出幾毫秒時,在主線程上執行該操作可能會導致整個UI變得很慢,更糟的是,可能會長時間僵死。如果主應用線程有幾秒時間無法處理事件隊列,則Android OS會由於應用沒有響應而結束該應用。Handler類和AsyncTask類支持程序員把運行慢或需要較長時間運行的任務委派給其他線程以便避免這個問題,進而確保主線程能夠繼續提供UI服務。這個例子說明了如何使用包含Handler的線程週期性地為UI執行入隊更新操作。

這個演示程序比較簡單。它把新創建的點加入隊列,並把它加入到主線程的點模型中。更複雜的應用可能是在創建時給模型傳遞一個主線程的Handler,並為UI提供從模型中獲取模型線程的Handler的方式。主線程會通過主線程Handler接收模型插入隊列中的更新事件。模型運行在自己的線程上,會使用Looper類從隊列中刪除和分發從UI得到的輸入消息。在考慮複雜的架構之前,首先應該考慮使用服務或ContentProvider(參見第13章)。

通過這種方式在UI和長時間運行的線程之間傳遞信息可以極大地降低維護線程安全的成本。特別地,回顧一下第3章的內容,如果入隊線程不包含插入隊列的對象的引用,或者如果該對象是不可改變的,那麼就不需要額外的同步操作。