實戰專案 9: 習慣記錄應用

HsuJin發表於2018-02-19

這篇文章分享我的 Android 開發(入門)課程 的第九個實戰專案:習慣記錄應用。這個專案託管在我的 GitHub 上,具體是 TrackYourRun Repository,專案介紹已詳細寫在 README 上,歡迎大家 star 和 fork。

這個實戰專案的主要目的是練習在 Android 中使用 SQLite 資料庫,不過在實際 coding 的過程中還使用了很多其它有意思的 Android 元件,這篇文章按例逐個分享給大家,希望對大家有幫助,歡迎互相交流。為了精簡篇幅,文中的程式碼有刪減,請以 GitHub 中的程式碼為準。

關鍵詞:DatePickerDialog & DatePickerFragment、TimePickerDialog & TimePickerFragment、Calendar & Date、SimpleDateFormat、EditText、AlertDialog、string-array、InputFilter、Spinner、SharedPreferences、saveInstanceState、Cursor、Intent Extras、SpannableStringBuilder、ItemTouchHelper、Snackbar、Adaptive Icons

Track Your Run App 是一個通過 SQLiteDatabase 記錄跑步資料的應用,重點在於 SQLite 資料庫的 CRUD 操作:使用者可以輸入每次跑步的日期、時間、時長、距離及其單位 (Create),應用會將每條記錄顯示在列表中 (Read);使用者可以點選每條記錄進行編輯 (Update),或者左滑刪除一條記錄 (Delete),來管理跑步資料列表。

一、建立資料庫與新增資料 (Create)

實戰專案 9: 習慣記錄應用

Track Your Run App 的首次啟動介面為一個帶 CompoundDrawable 的 Empty View,使用者點選後會 Intent 到 EditorActivity 編輯跑步資料。在 AppBar 的選單中也有一個“新增” (+) 按鈕作為 EditorActivity 的入口。

實戰專案 9: 習慣記錄應用

EditorActivity 作為編輯跑步資料的介面,使用者可以輸入跑步的日期、時間、時長、距離及其單位。

日期與時間

日期與時間分別顯示在兩個 TextView 中。預設情況下,通過 Calendar 獲取裝置當前的日期與時間,並使用 SimpleDateFormat 格式化後顯示出來。

In EditorActivity.java

Calendar calendar = Calendar.getInstance();
SimpleDateFormat dateFormat = new SimpleDateFormat("EEE, MMM d, yyyy", Locale.getDefault());
mDateView.setText(dateFormat.format(calendar.getTime()));
SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm", Locale.getDefault());
mTimeView.setText(timeFormat.format(calendar.getTime()));
複製程式碼

使用者點選 DateView 或 TimeView 後會開啟 DatePickerDialog 或 TimePickerDialog,兩者通過各自的 DialogFragment 實現。注意在呼叫 show method 顯示對話方塊時要為每個 DialogFragment 傳入一個獨一無二的標籤。

In EditorActivity.java

mDateView.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        DialogFragment fragment = new DatePickerFragment();
        fragment.show(getFragmentManager(), "datePicker");
    }
});

mTimeView.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        DialogFragment fragment = new TimePickerFragment();
        fragment.show(getFragmentManager(), "timePicker");
    }
});
複製程式碼

DatePickerFragment 和 TimePickerFragment 分別定義在單獨的 Java 檔案中,並在對應的類內實現監聽器。

DatePickerDialog

In DatePickerFragment.java

public class DatePickerFragment extends DialogFragment
        implements DatePickerDialog.OnDateSetListener {

    private static final String LOG_TAG = DatePickerFragment.class.getSimpleName();

    @Override
    public Dialog onCreateDialog(Bundle savedInstanceState) {
        final Calendar c = Calendar.getInstance();
        int year = c.get(Calendar.YEAR);
        int month = c.get(Calendar.MONTH);
        int day = c.get(Calendar.DAY_OF_MONTH);

        return new DatePickerDialog(getActivity(), this, year, month, day);
    }

    public void onDateSet(DatePicker view, int year, int month, int day) {
        try {
            TextView dateView = getActivity().findViewById(R.id.date_view);
  
            String dateIn = Integer.toString(year) + "-" +
                    Integer.toString(month + 1) + "-" +
                    Integer.toString(day);
            SimpleDateFormat inFormat = new SimpleDateFormat("yyyy-M-d",
                    Locale.getDefault());
            Date dateOut = inFormat.parse(dateIn);
            SimpleDateFormat outFormat =
                    new SimpleDateFormat("EEE, MMM d, yyyy", Locale.getDefault());

            dateView.setText(outFormat.format(dateOut));
        } catch (ParseException e) {
            Log.e(LOG_TAG, "Problem parsing the date ", e);
        }
    }
}
複製程式碼
  1. Override onCreateDialog method 定義建立對話方塊的方法,在這裡是通過 Calendar 獲取裝置當前的日期,並傳入返回的 DatePickerDialog 物件中,使對話方塊預設選中裝置當前的日期。method 的返回值為一個新的 DatePickerDialog 物件,其中第二個引數傳入 this 表示在當前類實現 OnDateSetListener 監聽器。
  2. 在 DatePickerFragment 類名後新增 implements 引數,並實現 onDateSet method 定義使用者選擇日期後執行的程式碼。在這裡即把日期作為字串設為 DateView 的文字顯示,不過在此之前需要通過 SimpleDateFormat 格式化日期,具體的做法是:
    (1)將 onDateSet method 的輸入引數定義為 "yyyy-M-d" 格式的字串,並使用 SimpleDateFormat 解析為 Date 物件。
    (2)將解析出的 Date 物件通過 SimpleDateFormat 格式化。

Note:
1. 由於呼叫了 SimpleDateFormat 的 parse method,所以 onDateSet method 內的程式碼要放在 try/catch 區塊中,並捕捉 ParseException 異常。
2. 在 onDateSet method 中,使用者選擇的月份是以數字格式傳入的,範圍為 0 ~ 11,這與 SimpleDateFormat 的月份範圍 (1 ~ 12) 相差一位,所以在把月份設為字串時需要加一。

TimePickerDialog

In TimePickerFragment.java

public class TimePickerFragment extends DialogFragment
        implements TimePickerDialog.OnTimeSetListener {

    private static final String LOG_TAG = TimePickerFragment.class.getSimpleName();

    @Override
    public Dialog onCreateDialog(Bundle savedInstanceState) {
        final Calendar c = Calendar.getInstance();
        int hour = c.get(Calendar.HOUR_OF_DAY);
        int minute = c.get(Calendar.MINUTE);

        return new TimePickerDialog(getActivity(), this, hour, minute,
                DateFormat.is24HourFormat(getActivity()));
    }

    public void onTimeSet(TimePicker view, int hourOfDay, int minute) {
        try {
            TextView timeView = getActivity().findViewById(R.id.time_view);

            String timeIn = hourOfDay + ":" + minute;
            SimpleDateFormat inFormat = new SimpleDateFormat("H:m",
                    Locale.getDefault());
            Date timeOut = inFormat.parse(timeIn);
            SimpleDateFormat outFormat =
                    new SimpleDateFormat("HH:mm", Locale.getDefault());

            timeView.setText(outFormat.format(timeOut));
        } catch (ParseException e) {
            Log.e(LOG_TAG, "Problem parsing the date ", e);
        }
    }
}
複製程式碼
  1. 與 DatePickerFragment 類似,onCreateDialog method 返回一個新的 TimePickerDialog 物件,預設選中裝置當前的時間,設定當前類實現 OnTimeSetListener 監聽器。不同的是,TimePickerDialog 物件的最後一個輸入引數為通過 DateFormat.is24HourFormat(getActivity()) 獲知裝置是否使用 24 小時制,這決定了 TimePickerDialog 的樣式。
  2. 類似地,在 TimePickerFragment 類名後新增 implements 引數,並實現 onTimeSet method 定義使用者選擇時間後執行的程式碼。把時間作為字串設為 TimeView 的文字顯示,不過在此之前需要通過 SimpleDateFormat 格式化時間,做法同樣是將輸入引數定義為字串,並使用 SimpleDateFormat 解析為 Date 物件,最後通過 SimpleDateFormat 格式化時間,在這裡只是保證小時數和分鐘數都為兩位數,例如零點五分顯示為 00:05,而不是 0:5。

時長

時長顯示在一個 TextView 中,預設情況下顯示 30 分鐘。使用者點選 DurationView 後會開啟一個單選列表的 AlertDialog,有四個時長選項可供選擇,分別為 30 分鐘、一個小時,兩個小時,以及自定義。

實戰專案 9: 習慣記錄應用

除了自定義選項,使用者選中某一項時長後,就把該項作為字串設為 DurationView 的文字顯示,通過在 DurationView 的 OnClickListener 實現 DialogInterface 的 OnClickListener 來完成。

In EditorActivity.java

mDurationView.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        AlertDialog.Builder builder = new AlertDialog.Builder(EditorActivity.this);
        builder.setItems(R.array.array_duration_options, new DialogInterface.OnClickListener() {
            public void onClick(DialogInterface dialog, int which) {
                switch (which) {
                    case RunEntry.DURATION_HALF_HOUR:
                        mDurationView.setText(R.string.duration_half_hour);
                        break;
                    case RunEntry.DURATION_ONE_HOUR:
                        mDurationView.setText(R.string.duration_one_hour);
                        break;
                    case RunEntry.DURATION_TWO_HOUR:
                        mDurationView.setText(R.string.duration_two_hour);
                        break;
                    case RunEntry.DURATION_CUSTOM:
                        customDurationDialog();
                        break;
                    default:
                        break;
                }
            }
        }).create().show();
    }
});
複製程式碼
  1. 通過 AlertDialog.Builder 設定時長的對話方塊,並通過 setItems method 設定對話方塊的列表項,在這裡使用了在 arrays.xml 定義的 ID 為 array_duration_options 的 string-array。
  2. 同時在 setItems method 設定對話方塊的監聽器,實現 onClick method,其中輸入引數 which 為被點選的列表項位置的數字程式碼,第一項位置為 0。在這裡使用 switch/case 語句判斷被點選的列表項,並且使用了 RunContract 中定義的常量。
  3. 最後不要忘記鏈式呼叫 create method 建立對話方塊,以及 show method 顯示對話方塊。

使用者點選自定義時長的選項後,會開啟一個新的 AlertDialog,裡面有兩個 EditText 分別用於輸入小時數和分鐘數,使用者輸入完成後點選 OK 按鈕就把自定義時間作為字串設為 DurationView 的文字顯示。其中,輸入小時數的 EditText 的提示符為數字 1,輸入分鐘數的則為 30,如果使用者未輸入任何數字,那麼自定義時長將設為預設的 1 小時 30 分鐘。

實戰專案 9: 習慣記錄應用

In EditorActivity.java

private void customDurationDialog() {
    View view = getLayoutInflater().inflate(R.layout.dialog_duration, null);
    final EditText hourEditText = view.findViewById(R.id.custom_duration_hour);
    final EditText minuteEditText = view.findViewById(R.id.custom_duration_minute);

        hourEditText.setFilters(new InputFilter[]{new InputFilter.LengthFilter(2),
                new EditTextInputFilter(1, 24)});
        minuteEditText.setFilters(new InputFilter[]{new InputFilter.LengthFilter(2),
                new EditTextInputFilter(1, 59)});

    AlertDialog.Builder builder = new AlertDialog.Builder(this);
    builder.setView(view).setTitle(R.string.custom_duration_title)
            .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int id) {
                    String hour = hourEditText.getText().toString().trim();
                    String minute = minuteEditText.getText().toString().trim();

                    String customDuration = getString(R.string.editor_hint_duration_hour) +
                            getString(R.string.time_hour) +
                            getString(R.string.editor_hint_duration_minute) +
                            getString(R.string.time_minutes);

                    // Set the right string format for hour/hours and minute/minutes.

                    mDurationView.setText(customDuration);
                }
            }).setNegativeButton(android.R.string.cancel, null).create().show();
}
複製程式碼
  1. 將設定自定義時長對話方塊的程式碼封裝成一個單獨的 method,優化程式碼結構。
  2. 由於對話方塊的兩個 EditText 的 XML 在一個單獨的 Layout 檔案中,所以需要先定義一個 inflate 該佈局的 View,然後才能通過 findViewById 找到 EditText,並且在 AlertDialog.Builder 中呼叫 setView method 將該檢視作為對話方塊的內容檢視。
  3. 在 AlertDialog.Builder 中呼叫 setTitle method 設定對話方塊的標題。
  4. 在 AlertDialog.Builder 中呼叫 setPositiveButton method 設定對話方塊的肯定按鈕,其中輸入引數分別為
    (1)按鈕的字串 ID,在這裡使用了 Android 自帶的 OK 字串資源;
    (2)按鈕的 OnClickListener,在這裡把 EditText 的文字整理為正確格式的字串設為 DurationView 的文字顯示。
  5. 在 AlertDialog.Builder 中呼叫 setNegativeButton method 設定對話方塊的否定按鈕,在這裡傳入了 Android 自帶的 Cancel 字串資源 ID 作為按鈕的字串,以及 null 表示不 override 按鈕的點選事件,使使用者點選該按鈕時對話方塊的動作保持預設(通常是關閉對話方塊)。
  6. 自定義時長的對話方塊帶有兩個 EditText 可供使用者輸入值,儘管在 XML 中已經將輸入型別限制為整數,但是使用者仍可輸入一些不合理的數字,例如 233 hr 666 min。所以這裡實現了一個 InputFilter 物件,呼叫 EditText 的 setFilters method 將輸入小時數的 EditText 範圍限制為 1 ~ 24,將輸入分鐘數的 EditText 範圍限制為 1 ~ 59。

In EditorActivity.java

private class EditTextInputFilter implements InputFilter {

    private double mMinValue, mMaxValue;

    private EditTextInputFilter(double minValue, double MaxValue) {
        mMinValue = minValue;
        mMaxValue = MaxValue;
    }

    @Override
    public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
        try {
            String inputString = dest.toString().substring(0, dstart) +
                    source.toString().substring(start, end) +
                    dest.toString().substring(dend, dest.toString().length());

            if (!inputString.isEmpty()) {
                double inputValue = Double.parseDouble(inputString);
             
                if (isInRange(mMinValue, mMaxValue, inputValue)) {
                    return null;
                }
            }
        } catch (NumberFormatException e) {
            Log.e(LOG_TAG, "Problem parsing the input number ", e);
        }
       
        return "";
    }

    private boolean isInRange(double minValue, double MaxValue, double inputValue) {
        return MaxValue > minValue && inputValue >= minValue && inputValue <= MaxValue;
    }
}
複製程式碼
  1. 由於 InputFilter 只在 EditorActivity 中用到,所以在 EditorActivity 內作為內部類實現 InputFilter,而沒有在單獨的 Java 檔案中,類名為 EditTextInputFilter,不要忘記在類名後面新增 implements 引數。
  2. EditTextInputFilter 的建構函式傳入最大值和最小值,資料型別為 double 以支援小數。
  3. Override filter method 定義實現輸入限制的程式碼,這個 method 會在 source 中的從 start 到 end 的 CharSequence 將要覆蓋 dest 中的從 dstart 到 dend 的快取時呼叫,根據返回值的不同,決定是允許輸入,還是替代為其它,實現輸入的過濾功能。在這裡 InputFilter 的工作原理是監控使用者輸入的每一個數字,當判斷到輸入屬於限制範圍內時,則返回 null 允許輸入;當判斷到輸入超出限制範圍時,則返回空字元 ("") 替代使用者的輸入,相當於將輸入過濾掉了。具體的做法是:
    (1)定義使用者輸入的字串,用 source 中的從 start 到 end 的字串替代 dest 中的從 dstart 到 dend 的字串,同時分別在前後新增 dest 中的從開頭到 dstart 以及 從 dend 到末尾的字串,保證獲取的是數字的實際大小。例如使用者首先輸入 10,然後將游標放到最前面再輸入 1,得到的應該數字是 110,但若未正確處理,獲得的數字可能是錯誤的 101。
    (2)使用 if 語句判斷上面定義的字串是否為空,僅在字串不為空時進行處理。這是因為當 EditText 內無內容時,字串為空,而通過 parseDouble method 解析空的字串會觸發 NumberFormatException 異常,錯誤資訊 (e) 為 empty string。儘管這段程式碼已經放在 try/catch 區塊中,也捕捉了相應的異常,允許靜默失敗,但最好的做法還是避免應用執行時發生錯誤。
    (3)通過輔助方法 isInRange 判斷使用者輸入是否在範圍內,返回值資料型別為布林型別,若使用者輸入屬於範圍內則返回 true,超出範圍則返回 false。

Tips:
在 EditText 的 XML 中可以通過 android:maxLength 屬性設定允許輸入的最大長度,如設定為 2 表示最多可輸入兩位數。但是如果在 Java 中呼叫了 setFilters method 就會覆蓋在 XML 中的設定,導致原先的設定無效。
因此,要在 Java 中設定允許在 EditText 輸入的最大長度,直接呼叫 InputFilter 的靜態方法 LengthFilter 並傳入所需的數字即可實現。在這裡將兩個 EditText 允許的最大長度都設定為 2,設定 InputFilter 的 LengthFilter 物件與上述 EditTextInputFilter 物件一同傳入 setFilters method。
注意 setFilters method 的輸入引數為一個 InputFilter[] 物件陣列,而 InputFilter[] 物件陣列可傳入多個 InputFilter 的 method 物件,分別用於設定 EditText 的不同過濾功能。

距離及其單位

跑步的距離直接輸入至對應的 EditText 中,支援小數;距離單位則通過 Spinner 選擇,支援公制 (km) 和英制 (mile)。

實戰專案 9: 習慣記錄應用

In EditorActivity.java

mDistanceEditText = findViewById(R.id.edit_distance);
mDistanceUnitSpinner = findViewById(R.id.spinner_distance_unit);

mDistanceEditText.setFilters(new InputFilter[]{new InputFilter.LengthFilter(4),
                          new EditTextInputFilter(1, 100)});
setupDistanceUnitSpinner();
複製程式碼
  1. 與自定義時長的 EditText 類似,設定距離的 EditText 通過 setFilters method 將允許輸入的最大長度限制為 4,包括小數點;將輸入範圍設定為 1 ~ 100。
  2. 將距離單位的 Spinner 封裝成一個單獨的 method 呼叫,優化程式碼結構。在設定 Spinner 的 method 中,根據使用者的選擇,將距離單位作為字串存入一個全域性變數,在提交運動資料時存入資料庫。

一般情況下,使用者設定距離單位後通常都不會改動。為了尊重這一使用者習慣,所以在使用者選擇某一項單位後,應用會將該項位置存入 SharedPreferences,並在設定 Spinner 時從 SharedPreferences 中提取先前使用者選擇的位置資訊,使其顯示正確的單位。這樣一來,Spinner 的選項就相當於一個偏好設定,無論是切換 Activity,還是退出應用,它都能顯示使用者選擇的單位,直到使用者重新選擇,或清除應用資料。

In EditorActivity.java

private void saveSpinnerPosition(int spinnerPosition) {
    SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
    SharedPreferences.Editor editor = sharedPreferences.edit();
    editor.putInt("spinnerPosition", spinnerPosition);
    editor.apply();
}

private void loadSpinnerPosition() {
    SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
    int position = sharedPreferences.getInt("spinnerPosition", -1);
    // Only when position is valid, set it to the spinner.
    if (position != -1) {
        mDistanceUnitSpinner.setSelection(position);
    }
}
複製程式碼
  1. Spinner 位置的儲存定義為單獨的 method 在 Spinner 監聽器的 onItemSelected method 中呼叫;類似地,Spinner 位置的載入也設為單獨的 method 在設定 Spinner 時呼叫。

  2. saveSpinnerPosition 中,將 Spinner 位置存入 SharedPreferences,主要通過呼叫其 method 實現,所以上述程式碼也可以通過一系列的鏈式呼叫實現。最後不要忘記呼叫 apply() method。

     PreferenceManager.getDefaultSharedPreferences(this).edit().putInt("spinnerPosition", spinnerPosition).apply();
    複製程式碼
  3. loadSpinnerPosition 中,將 Spinner 位置從 SharedPreferences 提取出來,呼叫 getInt method 實現,還需要為提取的變數提供一個預設值,在這裡將提取的 Spinner 位置的預設值設為無效的 -1。最後判斷提取的Spinner 位置是否有效,僅在位置有效時設定 Spinner 位置。

在編輯好所有跑步資料後,使用者可以點選 AppBar 的選單中的“提交” (√) 按鈕,將跑步的日期、時間、時長、距離及其單位統統存入 SQLite 資料庫。將新增資料的程式碼封裝成一個單獨的 method 呼叫,方法內都是常規操作。

In EditorActivity.java

private void insertRuns() {
    RunDbHelper mDbHelper = new RunDbHelper(this);

    SQLiteDatabase db = mDbHelper.getWritableDatabase();

    ContentValues values = new ContentValues();
    values.put(RunEntry.COLUMN_RUN_DATE, mDateView.getText().toString().trim());
    values.put(RunEntry.COLUMN_RUN_TIME, mTimeView.getText().toString().trim());
    values.put(RunEntry.COLUMN_RUN_DURATION, mDurationView.getText().toString().trim());
    values.put(RunEntry.COLUMN_RUN_DISTANCE,
            Double.parseDouble(mDistanceEditText.getText().toString().trim()));
    values.put(RunEntry.COLUMN_RUN_DISTANCE_UNIT, mDistanceUnit);

    long newRowId = db.insert(RunEntry.TABLE_NAME, null, values);

    if (newRowId == -1) {
        Toast.makeText(this, getString(R.string.run_add_error), Toast.LENGTH_SHORT).show();
    } else {
        Toast.makeText(this, getString(R.string.run_add_success), Toast.LENGTH_SHORT).show();
    }
}
複製程式碼
  1. 擴充套件自 SQLiteOpenHelper 的 RunDbHelper 用於建立資料庫及其版本管理,在 onCreate 中呼叫 SQLiteDatabase 的 execSQL method 建立資料庫,SQL 指令定義為字串傳入,包含表格名稱,以及六列名稱及其對應的儲存類和限制條件。

     String SQL_CREATE_RUNS_TABLE = "CREATE TABLE " + RunEntry.TABLE_NAME + " ("
             + RunEntry._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
             + RunEntry.COLUMN_RUN_DATE + " TEXT NOT NULL, "
             + RunEntry.COLUMN_RUN_TIME + " TEXT NOT NULL, "
             + RunEntry.COLUMN_RUN_DURATION + " TEXT NOT NULL, "
             + RunEntry.COLUMN_RUN_DISTANCE + " REAL NOT NULL DEFAULT 0,"
             + RunEntry.COLUMN_RUN_DISTANCE_UNIT + " TEXT NOT NULL);";
    複製程式碼
  2. 在 RunDbHelper 的 onUpgrade method 新增處理資料庫版本升級的程式碼,在這裡因為資料庫結構不發生變化,所以僅在方法內呼叫 deleteDatabase method 先刪除資料庫,然後再呼叫 onCreate method 重新建立資料庫。其中,由於 deleteDatabase method 屬於 Context 類,所以需要通過從建構函式傳入的應用環境 (mContext) 呼叫。

  3. 呼叫 RunDbHelper 的 getWritableDatabase method 獲取 SQLiteDatabase 物件,然後將從各個檢視獲取的字串資料放入 ContentValues 物件,最後呼叫 SQLiteDatabase 的 insert method 將資料新增到資料庫,其中 SQLiteDatabase 的 insert method 返回值為新增的新行 ID,出現錯誤時為 -1。

在新增資料到 SQLite 資料庫後,在 onOptionsItemSelected method 內呼叫 finish() method 關閉 EditorActivity,使應用返回 CatalogActivity 顯示跑步列表。另外,為了在裝置旋轉方向後,使用者輸入的跑步資料不會丟失,所以在 onSaveInstanceState method 儲存變數,並在 onCreate method 提取並設定到相應的檢視中。

實戰專案 9: 習慣記錄應用

二、讀取資料 (Read)

實戰專案 9: 習慣記錄應用

使用者編輯好跑步資料後,從 EditorActivity 中返回 CatalogActivity,在 onStart method 讀取資料庫中的資料,並顯示在 RecyclerView 列表中。將讀取資料的程式碼封裝成一個單獨的 method 呼叫,步驟與課程中介紹的相差無幾,都是通過 SQLiteDatabase 的 query method 將讀取的資料存入一個 Cursor 物件,然後通過 moveToNext() method 移動 Cursor 游標遍歷資料行,最後通過 try/finally 區塊保證在資料讀取完成後執行 close() method 關閉 Cursor,防止記憶體洩漏。另外,RecyclerView 的操作也與前幾個實戰專案的類似,在這裡不再贅述,完整程式碼請參考我的 GitHub TrackYourRun Repository。

Note:
在這裡,雖然跑步列表中不顯示資料庫中每行資料的 ID,但是仍要將 ID 存入 Run 物件中。這是因為每行資料的 ID 是獨一無二的,它將作為更新和刪除資料時的唯一憑證。

三、更新資料 (Update)

使用者點選列表中的某一個跑步項,就會跳轉到 EditorActivity 中編輯當前項的跑步資料。這個功能的關鍵點在於將跑步資料傳入 EditorActivity,並正確地顯示在相應的檢視中。因此,首先設定 RecyclerView 列表的子項監聽器動作為 Intent 到 EditorActivity,並且傳入必要的 Extras 資料。

In RunAdapter.java

@Override
public void onBindViewHolder(final MyViewHolder holder, int position) {

    ...

    if (mOnItemClickListener != null) {
        holder.listItemContainer.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                Intent intent = new Intent(mContext, EditorActivity.class);
                intent.putExtra("itemId", mRunsList.get(holder.getAdapterPosition()).getId());
                intent.putExtra("itemDate", mRunsList.get(holder.getAdapterPosition()).getDate());
                intent.putExtra("itemTime", mRunsList.get(holder.getAdapterPosition()).getTime());
                intent.putExtra("itemDuration", mRunsList.get(holder.getAdapterPosition()).getDuration());
                intent.putExtra("itemDistance", mRunsList.get(holder.getAdapterPosition()).getDistance());
                intent.putExtra("itemDistanceUnit", mRunsList.get(holder.getAdapterPosition()).getDistanceUnit());
                mContext.startActivity(intent);
            }
        });
    }
}
複製程式碼

接下來在 EditorActivity 的 onCreate method 提取 Intent 中的 Extras 資料,並設定到相應的檢視中顯示。結合在 onSaveInstanceState method 中儲存的變數,引入一個全域性變數 firstTimeRendering 作為 EditorActivity 是否為第一次啟動的指示器,具體的程式碼邏輯如下:

In EditorActivity.java

private boolean firstTimeRendering = true;

@Override
protected void onCreate(Bundle savedInstanceState) {

    ...

    if (savedInstanceState != null) {
        firstTimeRendering = savedInstanceState.getBoolean("firstTimeRendering");
    }

    if (!firstTimeRendering) {
        Bundle bundle = getIntent().getExtras();
        if (bundle != null) {
            mDateView.setText(bundle.getString("itemDate"));
            mTimeView.setText(bundle.getString("itemTime"));
            mDurationView.setText(bundle.getString("itemDuration"));
            mDistanceEditText.setText(String.valueOf(bundle.getDouble("itemDistance")));

            if (bundle.getString("itemDistanceUnit").equals(getString(R.string.distance_unit_kilo))) {
                mDistanceUnitSpinner.setSelection(RunEntry.DISTANCE_UNIT_KILO);
            } else if (bundle.getString("itemDistanceUnit").equals(getString(R.string.distance_unit_mile))) {
                mDistanceUnitSpinner.setSelection(RunEntry.DISTANCE_UNIT_MILE);
            }
        } else {
            Calendar calendar = Calendar.getInstance();
            SimpleDateFormat dateFormat =
                    new SimpleDateFormat("EEE, MMM d, yyyy", Locale.getDefault());
            mDateView.setText(dateFormat.format(calendar.getTime()));
            SimpleDateFormat timeFormat =
                    new SimpleDateFormat("HH:mm", Locale.getDefault());
            mTimeView.setText(timeFormat.format(calendar.getTime()));
        }

        firstTimeRendering = false;
    } else if (savedInstanceState != null) {
        mDateView.setText(savedInstanceState.getString("dateString"));
        mTimeView.setText(savedInstanceState.getString("timeString"));
        mDurationView.setText(savedInstanceState.getString("durationString"));
    }
}
複製程式碼
  1. 全域性變數 firstTimeRendering 初始化為 true,表示 EditorActivity 為第一次啟動。為了保證發生 Activity 重啟等情況時,變數 firstTimeRendering 的狀態不會丟失,所以要把它存入 savedInstanceState 中,並在 onCreate method 中提取出來。
  2. 當判斷 EditorActivity 為第一次啟動時,就從 Intent 獲取 Extras 資料,存入 Bundle 物件。
    (1)若 Bundle 為空,說明這是使用者新增一項跑步資料的情況,使跑步資料的日期與時間顯示為裝置當前的日期與時間、時長顯示預設的 30 分鐘、距離為空,距離單位根據 SharedPreferences 儲存的專案顯示。
    (2)若 Bundle 不為空,說明這是使用者更新一項跑步資料的情況,使各項跑步資料根據從 Intent 提取的 Extras 資料顯示。
    (3)在處理完畢後,將變數 firstTimeRendering 設為 false,保證 EditorActivity 僅在第一次啟動時從 Intent 獲取 Extras 資料,再3重啟時不會再進入這個 if 條件語句。
  3. 當發生裝置旋轉方向等情況,導致 EditorActivity 重啟時,各項跑步資料就根據 savedInstanceState 中儲存的狀態顯示,不再從 Intent 獲取 Extras 資料。

使用者完成跑步資料編輯後,同樣點選 AppBar 的選單中的“提交” (√) 按鈕,將跑步資料更新至 SQLite 資料庫的對應資料行中。此時就要通過判斷 Intent 中的 Extras 資料是否為空,來區分新增資料與更新資料兩種情況。與新增資料類似,更新資料的程式碼也封裝成一個單獨的 method 呼叫。

In EditorActivity.java

private void updateRuns(int itemId) {
    RunDbHelper mDbHelper = new RunDbHelper(this);

    SQLiteDatabase db = mDbHelper.getWritableDatabase();

    ContentValues values = new ContentValues();
    values.put(RunEntry.COLUMN_RUN_DATE, mDateView.getText().toString().trim());
    values.put(RunEntry.COLUMN_RUN_TIME, mTimeView.getText().toString().trim());
    values.put(RunEntry.COLUMN_RUN_DURATION, mDurationView.getText().toString().trim());
    values.put(RunEntry.COLUMN_RUN_DISTANCE,
            Double.parseDouble(mDistanceEditText.getText().toString().trim()));
    values.put(RunEntry.COLUMN_RUN_DISTANCE_UNIT, mDistanceUnit);

    String selection = RunEntry._ID + " LIKE ?";
    String[] selectionArgs = {String.valueOf(itemId)};

    long updatedRowId = db.update(RunEntry.TABLE_NAME, values, selection, selectionArgs);

    if (updatedRowId == -1) {
        Toast.makeText(this, getString(R.string.run_update_error), Toast.LENGTH_SHORT).show();
    } else {
        Toast.makeText(this, getString(R.string.run_update_success), Toast.LENGTH_SHORT).show();
    }
}
複製程式碼

更新資料的程式碼結構與新增資料的 insertRuns() method 類似,關鍵點在於根據資料行 ID 設定 SQLiteDatabase 的 update method 的 SQL 指令的篩選條件,保證僅更新正確的資料行,而不會更新資料庫中的所有資料。

四、刪除資料 (Delete)

Track Your Run App 提供了兩種刪除 SQLite 資料庫的資料的方法,第一種方法是點選 CatalogActivity 的 AppBar 的溢位選單中的 "Delete All Runs" 選項,應用會彈出一個 AlertDialog,警告使用者此操作無法恢復,使用者點選 OK 後即刪除資料庫中的所有資料。

實戰專案 9: 習慣記錄應用
實戰專案 9: 習慣記錄應用

In CatalogActivity.java

@Override
public boolean onOptionsItemSelected(MenuItem item) {
    switch (item.getItemId()) {

        ...

        case R.id.action_delete_all_entries:
            TextView warningText = new TextView(this);
            SpannableStringBuilder stringBuilder =
                    new SpannableStringBuilder(getString(R.string.deletion_warning));
            stringBuilder.setSpan(
                    new android.text.style.StyleSpan(android.graphics.Typeface.BOLD),
                    0, 8, SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
            warningText.setText(stringBuilder);
            warningText.setTextColor(getResources().getColor(android.R.color.black));
            warningText.setTextSize(TypedValue.COMPLEX_UNIT_PX,
                    getResources().getDimensionPixelOffset(R.dimen.dialog_message_text_size));
            warningText.setPadding(
                    getResources().getDimensionPixelOffset(R.dimen.dialog_spacing),
                    getResources().getDimensionPixelOffset(R.dimen.dialog_spacing),
                    getResources().getDimensionPixelOffset(R.dimen.dialog_spacing),
                    getResources().getDimensionPixelOffset(R.dimen.dialog_spacing));
            warningText.setGravity(Gravity.CENTER_VERTICAL);

            AlertDialog.Builder builder = new AlertDialog.Builder(this);
            builder.setTitle(R.string.confirm_deletion).setView(warningText)
                    .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
                        @Override
                        public void onClick(DialogInterface dialog, int id) {
                            deleteRuns(null);

                            mAdapter.clear();
                            mEmptyView.setVisibility(View.VISIBLE);
                        }
                    }).setNegativeButton(android.R.string.cancel, null).create().show();
            return true;
    }
    return super.onOptionsItemSelected(item);
}
複製程式碼
  1. onOptionsItemSelected 中設定 "Delete All Runs" 選項的 AlertDialog。首先,完全通過 Java 設定一個 TextView 作為 AlertDialog 的檢視。其中,通過 SpannableStringBuilder 的 setSpan method 使字串的前九個字元加粗;通過 getDimensionPixelOffset() 實現獨立畫素 (dp) 與畫素 (px) 之間的轉換。
  2. 設定 AlertDialog 的肯定按鈕的監聽器動作為呼叫 deleteRuns method,隨後清除 RecyclerView 列表,並顯示 Empty View。

刪除資料的程式碼同樣封裝成一個單獨的 method,其中輸入引數為可 null 的資料行 ID,注意資料型別要寫成 int 的物件 Integer。當傳入 null 時,刪除資料庫中的所有資料;當傳入一個資料行 ID 時,則刪除該行資料,利用傳入 ID 定義 SQL 指令的篩選條件。

private void deleteRuns(@Nullable Integer itemId) {
    SQLiteDatabase db = mDbHelper.getWritableDatabase();

    if (itemId == null) {
        db.delete(RunEntry.TABLE_NAME, null, null);
    } else {
        String selection = RunEntry._ID + " LIKE ?";
        String[] selectionArgs = {String.valueOf(itemId)};

        db.delete(RunEntry.TABLE_NAME, selection, selectionArgs);
    }
}
複製程式碼

第二種刪除資料的方法時左滑 RecyclerView 列表的某一個子項,使用者可以看到子項滑出後顯示的刪除圖案和文字。這種佈局可以使用 FrameLayout 作為根檢視實現,注意顯示在頂層的列表子項檢視要設定背景顏色,否則會是預設的透明,使底層的檢視顯示出來,導致兩層檢視重疊在一起。

實戰專案 9: 習慣記錄應用

使 RecyclerView 的子項支援左滑手勢操作,需要引入 ItemTouchHelper。在單獨的 Java 檔案中定義 RecyclerItemTouchHelper 類,具體可參考 這個 Android Hive 教程。然後在 CatalogActivity 中實現它的監聽器,override onSwiped method 新增檢測到左滑手勢時執行的指令。

In CatalogActivity.java

public class CatalogActivity extends AppCompatActivity
        implements RecyclerItemTouchHelper.RecyclerItemTouchHelperListener {

    @Override
    public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction, int position) {
        if (viewHolder instanceof RunAdapter.MyViewHolder) {
            final Run deletedItem = mRunsList.get(viewHolder.getAdapterPosition());
            final int deletedIndex = viewHolder.getAdapterPosition();

            mAdapter.removeItem(viewHolder.getAdapterPosition());

            Snackbar.make(findViewById(R.id.catalog_container), getString(R.string.run_delete), Snackbar.LENGTH_LONG)
                    .setAction(R.string.action_undo, new View.OnClickListener() {
                        @Override
                        public void onClick(View view) {
                            mAdapter.restoreItem(deletedItem, deletedIndex);

                            if (deletedIndex == 0) {
                                mRecyclerView.scrollToPosition(0);
                            }
                        }
                    })
                    .addCallback(new Snackbar.Callback() {
                        @Override
                        public void onDismissed(Snackbar transientBottomBar, int event) {
                            if (event != Snackbar.Callback.DISMISS_EVENT_ACTION) {
                                deleteRuns(deletedItem.getId());

                                if (event == Snackbar.Callback.DISMISS_EVENT_TIMEOUT &&
                                        mAdapter.getItemCount() == 0) {
                                    deleteRuns(null);
                                    mEmptyView.setVisibility(View.VISIBLE);
                                }
                            }
                        }
                    })
                    .show();
        }
    }
}
複製程式碼
  1. 應用檢測到左滑手勢時,首先儲存該子項的 Run 物件,以及該子項在 RecyclerView 列表中的位置。它們會在後面的操作中用到。
  2. 隨後呼叫 RecyclerView 介面卡的 removeItem method 從 RecyclerView 列表中移除該子項,然後設定 Snackbar 顯示在螢幕的底部,提供使用者一個撤銷刪除的機會。

實戰專案 9: 習慣記錄應用

  1. 在 Snackbar 的 make method 中,第一個輸入引數需要傳入一個母檢視,使 Snackbar 顯示在合適的位置。由於這裡的佈局較簡單,所以這裡傳入的母檢視為 RelativeLayout,比較好的做法是傳入 CoordinatorLayout 使 Snackbar 繼承一些 Android 特性,例如 Snackbar 顯示時 FloatingActionButton 會自動上移,而不會被 Snackbar 覆蓋。
  2. 在 Snackbar 的 setAction method 中實現其監聽器,設定點選 UNDO 撤銷按鈕時的動作,在這裡呼叫RecyclerView 介面卡的 restoreItem method 恢復剛剛刪除的子項,傳入上面儲存的 Run 物件及其列表位置。另外,如果恢復的子項在列表的頂端,還需要呼叫 RecyclerView 的 scrollToPosition method 使列表上滾到頂端,使恢復的子項可見。
  3. 由於應用通過 Snackbar 為使用者提供了一個撤銷刪除的機會,所以不能在檢測到左滑手勢時馬上刪除資料庫中的資料,只能在 Snackbar 消失後再刪除。因此,設定 Snackbar 的回撥函式,override onDismissed method 新增刪除資料庫中的資料的程式碼。
    (1)當 Snackbar 不是因為點選了 UNDO 撤銷按鈕而消失時,有可能是 Snackbar 顯示完全,超時消失,也有可能是連續刪除子項,使後面的 Snackbar 覆蓋了之前的,此時刪除資料庫中的資料行,這裡傳入了上面儲存的 Run 物件的資料行 ID。 (2)當檢測到列表中的所有項都被刪除,且 Snackbar 因為超時而消失時,刪除資料庫中的所有資料,並顯示 Empty View。

Something More

實戰專案 9: 習慣記錄應用

從 Android 8.0 (API Level 26) 以來,Android 引入了 Adaptive Icons 應用啟動圖示。它能夠根據不同裝置顯示不同的形狀,同時提供觸控反饋等動畫效果。在 Android Studio 中能夠通過 Image Asset Studio 很輕鬆地實現 Adaptive Icons,主要工作是設定前景圖片,背景圖片或顏色,調整不同 API 情況下生成的圖示,完成後 Android Studio 就會自動生成所需的檔案,完成 Adaptive Icons。

實戰專案 9: 習慣記錄應用

相關文章