分類
Refactoring

Create test project for android project

保持重構後程式運行的正確性是重構前的第一考量

因此在重構前一定要先建立對應的測試系統,這裡使用 junit 來建立 android project 的 test project
 

1. 為了避免測試碼和程式碼的混淆,另外建立 android test project 來存放 test code

Eclipse -> File -> New -> Other -> Android Test Project
 

2. 在 android test project 根據要測試的 class 建立相應的 package 和 test class

target class -> GameMap.java in com.foxx.map package
Create test class -> GameMapTest.java in com.foxx.map.test package
 

3. 開始在 test class 撰寫 test case

Target class(GameMap.java)如下,我的任務是了解這個class,並將它重構

/**
 * Map of game
 */
public class GameMap implements IGameMap
{
    private static final int MAP_UNKNOW = 0;
    private static final int MAP_BARRIER = 1;
    private static final int MAP_BLANK = -1;
    private static final int RAW_MAP_DATA_ONE = 1;
    private static final int BASIC_MAP_INDEX = 0;
    protected int mMapWidth;
    protected int mMapHeight;
    protected Bitmap mMapBitmap = null;
    protected Canvas mCanvas;
    private Context mContext;
    public boolean mIsMerging = true;
    public boolean mIsConvertDisable = false;
    public GameMap(Context context)
    {
        mContext = context;
    }
    @Override
    public void createMap(Map map)
    {
        ArrayList<Map> maps = new ArrayList<Map>();
        maps.add(map);
        createMaps(maps);
    }
    @Override
    public void createMaps(ArrayList<Map> maps)
    {
        if (maps.isEmpty() || mIsConvertDisable)
            return;
        if (maps.get(0) == null)
            return;
        //obtain raw map's width and height
        mMapWidth = maps.get(0).getWidth();
        mMapHeight = maps.get(0).getHeight();
        // initial empty map
        if (mMapBitmap == null) {
            if (mMapWidth <= 0 || mMapHeight <= 0)
                return;
            mMapBitmap = Bitmap.createBitmap(mMapWidth, mMapHeight,
                    Bitmap.Config.ARGB_4444);
            mCanvas = new Canvas(mMapBitmap);
        }
        mIsMerging = true;
        drawMapsWithColor(maps);
        mIsMerging = false;
    }
    private void drawMapsWithColor(ArrayList<Map> maps)
    {
        for (Map map : maps) {
            int[] coloredMap = TransforByteArrayToColoredMap(map);
            if (coloredMap != null) {
                mCanvas.drawBitmap(coloredMap, 0, map.getWidth(), 0, 0,
                        map.getWidth(), map.getHeight(), true, null);
            }
        }
    }
    // change byte raw data into a color raw data
    private int[] TransforByteArrayToColoredMap(Map map)
    {
        byte[] prePixels = map.getData();
        int length = prePixels.length;
        int[] colorData = null;
        try {
            colorData = new int[length];
        } catch(OutOfMemoryError E){
            mIsConvertDisable = true;
            return null;
        }
        // the color should be discussed
        int colorUnknow = mContext.getResources().getColor(
                R.color.map_unknow);
        int colorBarrier = mContext.getResources().getColor(
                R.color.map_barrier);
        int colorBlank = mContext.getResources().getColor(
                R.color.map_space);
        int colorOfVirtualWall = mContext.getResources().getColor(
                R.color.vw_paint);
        int colorOfTouchZone = mContext.getResources().getColor(
                R.color.tz_save);
        int mapIdx = map.getIndex();
        if (mapIdx == BASIC_MAP_INDEX) { // basic map
            for (int i = 0; i < length; ++i) {
                switch (prePixels[i]) {
                case MAP_UNKNOW:
                    colorData[newArrayPtr(i)] = colorUnknow;
                    break;
                case MAP_BARRIER:
                    colorData[newArrayPtr(i)] = colorBarrier;
                    break;
                case MAP_BLANK:
                    colorData[newArrayPtr(i)] = colorBlank;
                    break;
                }
            }
        } else if ((mapIdx >= Fly.VIRTUALWALL_LOWERBOUND)
                && (mapIdx <= Fly.VIRTUALWALL_UPPERBOUND)) {
            // map of virtual wall
            for (int i = 0; i < length; ++i) {
                if (prePixels[i] == RAW_MAP_DATA_ONE)
                    colorData[newArrayPtr(i)] = colorOfVirtualWall;
            }
        } else if (mapIdx < Fly.COVERAGEMAP_MAPINDEX) {
            // map of clean area
            for (int i = 0; i < length; ++i) {
                if (prePixels[i] == RAW_MAP_DATA_ONE)
                    colorData[newArrayPtr(i)] = colorOfTouchZone;
            }
        } else { // map of other raw data
            String color = getColorOfMap(map.getIndex());
            for (int i = 0; i < length; ++i) {
                if (prePixels[i] == RAW_MAP_DATA_ONE)
                    colorData[newArrayPtr(i)] = Color.parseColor(color);
            }
        }
        return colorData;
    }
    private String getColorOfMap(int mapIndex)
    {
        String color = null;
        GlobalData globalData = (GlobalData) mContext.getApplicationContext();
        String model = globalData.Info().getModel();
        MapManager mapManager = new MapManager(mContext);
        List<MapLayer> supportedMapLayers = mapManager
                .getSupportedLayers(model);
        for (MapLayer map : supportedMapLayers) {
            if (mapIndex == Integer.valueOf(map.getMapIndex()))
                color = map.getColor();
        }
        return color;
    }
    // create new array pointer, startup from left-up side
    private int newArrayPtr(int oldPtr)
    {
        int newPtr;
        int remainderNum = oldPtr % mMapWidth;
        int quotientNum = (oldPtr - remainderNum) / mMapWidth;
        newPtr = mMapWidth * (mMapHeight - quotientNum - 1) + remainderNum;
        return newPtr;
    }
    @Override
    public Bitmap getBlendingBitmap()
    {
        return mMapBitmap;
    }
    @Override
    public boolean isMergingMaps()
    {
        return mIsMerging;
    }
    @Override
    public void disableConvert(boolean isDisable)
    {
        mIsConvertDisable = isDisable;
    }
    @Override
    public boolean isDisableConvert()
    {
        return mIsConvertDisable;
    }
}

 
GameMap有不少code smell,之後我會一一列舉出來並嘗試修正,但首先必須先建立test class(GameMapTest.java)如下, 選擇 junit version 3,
GameMapTest繼承AndroidTestCase是因為GameMap的建構式只需要傳入Context,並不需要Activity。
反之,如果今天要測試的為Activity就必須繼承ActivityInstrumentationTestCase2。

public class GameMapTest extends AndroidTestCase
{
    private GameMap mGameMap;
    private Map mMap;
    @Override
    protected void setUp() throws Exception
    {
        super.setUp();
        mMap = new Map(0, 2, 2, new byte[]{0,0,1,1});
        mGameMap = new GameMap(getContext());
        testCreateMap();
    }
    public void testCreateMap(){
        mGameMap.createMap(mMap);
        assertNotNull(mGameMap);
    }
    public void testGetBlendingBitmap(){
        Bitmap bitmap = mGameMap.getBlendingBitmap();
        assertNotNull(bitmap);
    }
    public void testMerginMap(){
        boolean isMapMergin = mGameMap.isMergingMaps();
        assertEquals(false, isMapMergin);
    }
    public void testGetDisableConvert()
    {
        boolean isDisableConvert = mGameMap.isDisableConvert();
        assertEquals(false, isDisableConvert);
    }
    public void testDisableConvert(){
        mGameMap.disableConvert(true);
        boolean isDisableConvert = mGameMap.isDisableConvert();
        assertEquals(isDisableConvert, true);
    }
}

繼承 AndroidTestCase 是為了取得 context 物件並傳入 GameMap 建構式,
Test Case 基本上保持一個方法測試一個功能,而 Map mMap 則是測試資料(test fixture),也是為了要驗證重構後結果正確,setUp 方法會在所有測試方法啟動前先執行,
通常會在這裡建立測試資料,寫完 test code 就先跑跑看吧,接上實機執行 test case
 
如果想驗證測試機制的確可以運行,也可以故意寫錯一些條件,讓junit告訴你,至於 test coverage 要多少,在Refactoring有提到可以運行少部份的測試,主要是驗證重構的結果即可
下篇開始重構 GameMap.java !!!

分類
Design Pattern Refactoring

使用策略模式(Strategy Pattern)取代 switch

一般來說使用switch通常會產生一些缺點,e.g.
1. 同樣的 switch 散佈在不同的位置時, 當新的需求出現而需要修改 switch 時,必須找出所有的 switch 一併修改。
2. 因為 switch 代表做了複數的工作,導致 switch 很難符合開閉原則 (OCP)。
3. switch 中條件區塊擴大時會讓 switch 越來越難以理解。
以上的因素也可套用到 if-else。
因此可考慮使用 Strategy Pattern 取代 switch 或是 if-else
簡單的switch如下

    private static void useSwitchExample(){
        switch(condition){
            case 0:
                System.out.println("audio error");
                break;
            case 1:
                System.out.println("client error");
                break;
            case 2:
                System.out.println("not match");
                break;
        }
    }

 
根據 condition 執行不同的 case 條件,會印出不同的錯誤訊息,為了取代 switch ,先建立 Strategy 如下

public interface IErrorStrategy
{
    public void showErrorMessage();
   
    class AudioError implements IErrorStrategy{
        @Override
        public void showErrorMessage()
        {
            System.out.println("audio error");
        }
    }
   
    class ClientError implements IErrorStrategy{
        @Override
        public void showErrorMessage()
        {
            System.out.println("client error");
        }
    }
   
    class NoMatchError implements IErrorStrategy{
        @Override
        public void showErrorMessage()
        {
            System.out.println("no match");
        }
    }
}

 
在 showErrorMessage 方法即是印出各個錯誤代碼,接著建立 Manager 來控制 Strategy e.g.,

public class ErrorStrategyManager
{
    public static final int AUDIO_ERROR = 0;
    public static final int CLIENT_ERROR = 1;
    public static final int NOMATCH_ERROR = 2;
    private Map<Integer, IErrorStrategy> mErrors = new HashMap<Integer, IErrorStrategy>();
    public ErrorStrategyManager() {
        initErrors();
    }
    private void initErrors()
    {
        mErrors.put(CLIENT_ERROR, new IErrorStrategy.ClientError());
        mErrors.put(AUDIO_ERROR, new IErrorStrategy.AudioError());
        mErrors.put(NOMATCH_ERROR, new IErrorStrategy.NoMatchError());
    }
    public void showErrorMessage(int errorMessage)
    {
        IErrorStrategy error = mErrors.get(errorMessage);
        error.showErrorMessage();
    }
}

 
其中使用 map 來對應不同的情況對應不同的 Strategy ,便可取代原本的 switch

    private static void useStrategyInsteadSwitchExample(){
        new ErrorStrategyManager().showErrorMessage(condition);
    }

 
以上的處理方式有個缺點,如果傳入的數值沒有在對應的項目之中就會出現 NullPointerException. e.g.,

new ErrorStrategyManager().showErrorMessage(100);

 
因此我們必須考慮 “例外” 的情況發生,另外判斷 null 的狀態, e.g.,

    public void showErrorMessage(int errorMessage) {
        IErrorStrategy error = mErrors.get(errorMessage);
        if (error != null) {
            error.showErrorMessage();
        } else {
            System.out.println("no this error message");
        }
    }

 
或是確實使用 “例外” 來處理,端看是否把該情況視為例外。

    public void showErrorMessage(int errorMessage) {
        try {
            IErrorStrategy error = mErrors.get(errorMessage);
            error.showErrorMessage();
        } catch (NullPointerException e) {
            System.out.println("no this error message");
        }
    }

 
以下是另外一個範例。
對字串做檢查,判斷字串是否符合某些規則。
一般來說最簡單直覺的方式就是以 if-else 來判斷 e.g.,

        public boolean checkData(String data)
        {
            if(data == null){
                return false;
            }else if(data.equals("")){
                return false;
            }
            return true;
        }

 
當然也具有在開頭提到的種種缺點,因此我們一樣使用 Strategy 來取代 if-else

public interface DataFormatChecker
{
    public static final String TAG = DataFormatChecker.class.getSimpleName();
    public boolean checkData(String data);
    class CheckAllRules implements DataFormatChecker
    {
        private Collection<DataFormatChecker> mRules = new ArrayList<DataFormatChecker>();
        public CheckAllRules() {
            mRules.add(new CheckNull());
            mRules.add(new CheckEmpty());
        }
        @Override
        public boolean checkData(String data)
        {
            for (DataFormatChecker checker : mRules) {
                if (checker.checkData(data)) {
                    Log.d(TAG, "which checker:" + checker.getClass().getSimpleName());
                    return false;
                }
            }
            return true;
        }
    }
    class CheckNull implements DataFormatChecker
    {
        @Override
        public boolean checkData(String data)
        {
            return null == data;
        }
    }
    class CheckEmpty implements DataFormatChecker
    {
        @Override
        public boolean checkData(String data)
        {
            return data.equals("");
        }
    }
}

第7行的 CheckAllRules 會將所有的規則物件放到 list 中,在其 checkData 方法(第18行)將這些規則物件取出並比對字串與規則。
之後若有新的規則只要新增 CheckXXX 類別 implements DataFormatChecker,再加入到 CheckAllRules 的 list 中。
外部呼叫 e.g.,

String data = "1234";
new DataFormatChecker.CheckAllRules().checkData(data);

 
另外也可使用 enum 取代 CheckAllRules e.g,

public interface DataFormatChecker
{
    ...
    public enum CheckAllRules {
        CHECK_NULL(new DataFormatChecker.CheckNull()),
        CHECK_EMPTY(new DataFormatChecker.CheckEmpty());
        private DataFormatChecker mChecker;
        private CheckAllRules(DataFormatChecker checker) {
            mChecker = checker;
        }
        public static boolean checkData(String data)
        {
            for (CheckAllRules type : CheckAllRules.values()) {
                if (type.mChecker.checkData(data)) {
                    Log.d(TAG, "check data error:" + type.mChecker.getClass().getSimpleName());
                    return false;
                }
            }
            Log.d(TAG, "check data valid");
            return true;
        }
    }
    ...
}

 
若有新的規則需求只要新增 CHECK_XXX(new DataFormatChecker.CheckXXX()) 在 enum 中,
並實作 DataFormatChecker.CheckXXX 內容 e.g.。

    public enum CheckAllRules {
        CHECK_NULL(new DataFormatChecker.CheckNull()),
        CHECK_EMPTY(new DataFormatChecker.CheckEmpty()),
        CHECK_LENGTH_MAX(new DataFormatChecker.CheckLengthMax());
        ...
    }
    ...
    class CheckLengthMax implements DataFormatChecker
    {
        @Override
        public boolean checkData(String data)
        {
            return data.length() > 100;
        }
    }

 
外部呼叫不需要修改(符合 OCP),但我們已經新增了另一項檢查規則(Check Length Max)了。

String data = "1234";
new DataFormatChecker.CheckAllRules().checkData(data);