Android Java

Ứng dụng Android quản lý chi tiêu cá nhân bằng SQLite

CRUD thu/chi, tiền nguyên đồng, lọc tháng, ngân sách danh mục, khóa ngoại và truy vấn báo cáo có kiểm thử.

Thực hànhDự án thực hành nâng cao28 phút đọcJava · Android · SQLite

Thích và đánh giá Bình luận và hỏi đáp
Giao dịch liên kết danh mục; báo cáo tháng tính mọi dòng và giữ ngân sách chưa phát sinh chi.
Giao dịch liên kết danh mục; báo cáo tháng tính mọi dòng và giữ ngân sách chưa phát sinh chi. Mở hình lớn ↗

CRUD thu/chi, tiền nguyên đồng, lọc tháng, ngân sách danh mục, khóa ngoại và truy vấn báo cáo có kiểm thử. Các tệp nguồn, Gradle wrapper, hướng dẫn chạy và kiểm thử đi kèm trong ZIP.

Xem trọn bộ 5 dự án Android · Tải dự án Android đầy đủ (.zip) · Tải APK debug 1.1 để cài thử

1. Phạm vi ứng dụng và dữ liệu mẫu

Ứng dụng cho phép thêm, sửa, xóa khoản thu/chi, xem tổng theo tháng và đặt ngân sách cho bốn danh mục Lương, Ăn uống, Đi lại, Khác. Mọi dữ liệu nằm trong SQLite trên thiết bị. Mục tiêu học tập là một đường đi hoàn chỉnh từ biểu mẫu tới cơ sở dữ liệu rồi quay lại báo cáo, với các ranh giới thời gian và tiền tệ được định nghĩa rõ.

Tải ZIP ở đầu bài, giải nén rồi mở thư mục có settings.gradle bằng Android Studio. Trong SDK Manager cài Android SDK Platform 35; chọn Gradle JDK 17. Dự án khóa Android Gradle Plugin 8.6.1 và Gradle 8.7, minSdk 26. Chờ Sync rồi chạy module app trên Android 8.0 trở lên. Lần đầu cần mạng để tải công cụ; bản ứng dụng sau khi cài hoạt động ngoại tuyến. Không cần khóa API, .NET hay dịch vụ AI trả phí.

Windows:       gradlew.bat assembleDebug
Linux/macOS:   chmod +x gradlew
               ./gradlew assembleDebug
APK: app/build/outputs/apk/debug/app-debug.apk

Nếu báo không tìm thấy SDK, mở lại dự án bằng Android Studio để tạo local.properties theo máy; không chép đường dẫn SDK từ máy khác. Nếu Gradle báo sai phiên bản Java, kiểm tra Gradle JDK thay vì chỉ kiểm tra lệnh java -version. Cấu hình này là một bộ phiên bản tương thích để học, không khẳng định là phiên bản Android mới nhất.

2. Thiết kế lược đồ và tiền nguyên đồng

Bảng categories định danh danh mục; transactions có kind, amount, category_id, day, note; budgets đặt hạn mức theo khóa (category_id,month). amount và limit_vnd dùng INTEGER, nhận qua Java long; không dùng float hoặc double để lưu số tiền VND. Một giao dịch từ 1 tới 10^12 đồng; SQL cũng kiểm tra kiểu integer và phạm vi để dữ liệu bất hợp lệ không chỉ bị chặn ở giao diện.

CREATE TABLE transactions(
  id INTEGER PRIMARY KEY,
  kind TEXT NOT NULL CHECK(kind IN ('IN','OUT')),
  amount INTEGER NOT NULL CHECK(
    typeof(amount)='integer' AND amount>0 AND amount<=1000000000000),
  category_id INTEGER NOT NULL REFERENCES categories(id),
  day TEXT NOT NULL CHECK(length(day)=10),
  note TEXT NOT NULL DEFAULT ''
);

Ngày lưu dạng ISO yyyy-MM-dd. Kiểm tra chiều dài trong SQL không đủ để nhận biết 30/2; ExpenseDb còn dùng LocalDate.parse và đối chiếu chuỗi chuẩn trước khi ghi. Từ Android API 26, lớp thời gian này có thể dùng trực tiếp trong phạm vi dự án. Nâng minSdk xuống thấp hơn sẽ cần giải pháp tương thích tương ứng, không chỉ sửa một con số trong Gradle.

3. SQLiteOpenHelper và khóa ngoại

ExpenseDb đọc assets/schema.sql trong onCreate, bật khóa ngoại ở onConfigure và mở DB qua getWritableDatabase/getReadableDatabase. Không gọi SQLite trực tiếp trong onDraw hoặc listener trên main thread. DbActivity cung cấp một hàng đợi thực thi nền và chỉ đưa kết quả sang main thread để cập nhật View; các nút tạm khóa trong lúc ghi giúp hạn chế gửi lặp.

Trong onUpgrade, dự án chưa có migration mới và chủ động báo cần viết migration nếu tăng phiên bản. Không dùng DROP TABLE làm “sửa lỗi database”, vì làm mất sổ chi tiêu. Khi thêm tài khoản hay trường thanh toán, hãy xây migration từ schema cũ trên một DB có dữ liệu tiếng Việt rồi kiểm tra tổng trước/sau. Tạo lại DB trống không kiểm chứng được bảo toàn dữ liệu.

4. Thêm, sửa, xóa và tham số hóa

Biểu mẫu có loại thu/chi, danh mục, số tiền, ngày và ghi chú. selected bằng 0 nghĩa là thêm, khác 0 nghĩa là cập nhật bản ghi được chọn. ContentValues giữ giá trị ghi; selectionArgs giữ điều kiện WHERE. Một ghi chú có dấu nháy như O’Reilly hoặc chứa chữ DROP TABLE phải được lưu như dữ liệu, không ghép vào câu SQL thực thi.

ContentValues values = new ContentValues();
values.put("amount", amount);
values.put("note", note);
int affected = db.update("transactions", values,
        "id=?", new String[]{String.valueOf(id)});
if (affected != 1) throw new IllegalArgumentException("Bản ghi không còn tồn tại");

Sau khi chọn dòng, nút Lưu cập nhật đúng id; Bản ghi mới bỏ id đã chọn để không vô tình sửa khoản trước. Xóa có xác nhận. Số dòng tác động phải được kiểm tra: báo thành công khi id không còn tồn tại tạo cảm giác dữ liệu đã lưu dù thực tế không thay đổi. Nếu sau này có đồng bộ nhiều thiết bị, cần thêm version hoặc cơ chế phát hiện ghi đè chứ không chỉ kiểm tra id.

5. Tổng theo tháng dùng khoảng nửa mở

Lấy ngày đầu tháng làm from, ngày đầu tháng kế tiếp làm to; điều kiện là day>=from AND day<to. Cách này không phải đoán tháng có 28, 29, 30 hay 31 ngày. Vì mọi ngày đã được chuẩn hóa ISO, thứ tự chuỗi phù hợp thứ tự ngày trong miền đã chọn. Chỉ lọc bằng tiền tố tháng khi đã hiểu dạng lưu; không trộn dd/MM/yyyy và yyyy-MM-dd trong cùng cột.

YearMonth ym = YearMonth.parse("2026-02");
String from = ym.atDay(1).toString();
String to = ym.plusMonths(1).atDay(1).toString();
// [2026-02-01, 2026-03-01), không chứa ngày 01/03

Thu và chi cộng bằng CASE/COALESCE để tháng rỗng cho 0. Chênh lệch thu trừ chi trong tháng không phải số dư tài khoản, vì bản mẫu chưa có số dư đầu kỳ, chuyển khoản giữa ví hoặc công nợ. Giao diện ghi đúng nhãn này. Danh sách chỉ hiện 100 dòng gần nhất, còn truy vấn tổng tính toàn bộ dòng trong tháng; cần nói rõ để người học không lấy tổng các dòng đang thấy làm số liệu báo cáo.

6. Ngân sách và phép nối giữ danh mục chưa chi

Người dùng chọn một danh mục rồi đặt ngân sách cho tháng đang xem. Bảng budgets có khóa kép giúp mỗi danh mục chỉ có một hạn mức trong tháng. Báo cáo dùng LEFT JOIN transactions và đặt điều kiện ngày ở phần ON để một ngân sách chưa có chi vẫn hiện với mức sử dụng 0. Đưa điều kiện ngày vào WHERE một cách không cẩn thận sẽ loại dòng chưa có giao dịch, làm danh mục biến mất.

Chỉ kind OUT làm tăng used; khoản IN không được trừ vào chi của danh mục. Ví dụ hạn mức Ăn uống 20.000, khoản chi 25.000 báo vượt; sửa xuống 18.000 thì hết vượt; xóa giao dịch thì vẫn còn ngân sách với used=0. Ba trạng thái này có trong bộ kiểm thử SQL để bắt lỗi JOIN và lọc thu/chi.

7. Mã lớp dữ liệu và các tệp cần mở

Môi trường thực hành. Dự án Android Java đầy đủ được tải bằng liên kết ZIP ở đầu bài. Android Studio, JDK 17, AGP 8.6.1, Gradle 8.7, compileSdk 35 và thiết bị API 26 trở lên. Dùng Activity/Canvas/SQLite gốc; xem README để chạy và kiểm tra. Tệp dưới đây là một thành phần của dự án.

Java · Android · SQLite · ExpenseDb.javaTải mã nguồn
package vn.kienutt.expense;

import android.content.*;
import android.database.*;
import android.database.sqlite.*;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.time.*;
import java.util.*;

public final class ExpenseDb extends SQLiteOpenHelper {
  private final Context context;

  public ExpenseDb(Context c) {
    super(c, "expenses.db", null, 1);
    context = c.getApplicationContext();
  }

  @Override
  public void onConfigure(SQLiteDatabase db) {
    db.setForeignKeyConstraintsEnabled(true);
  }

  @Override
  public void onCreate(SQLiteDatabase db) {
    try (InputStream in = context.getAssets().open("schema.sql")) {
      String sql = new String(read(in), StandardCharsets.UTF_8);
      for (String s : sql.split(";")) if (!s.trim().isEmpty()) db.execSQL(s);
    } catch (IOException e) {
      throw new IllegalStateException("Không đọc được schema", e);
    }
  }

  private static byte[] read(InputStream in) throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    byte[] b = new byte[4096];
    int n;
    while ((n = in.read(b)) != -1) out.write(b, 0, n);
    return out.toByteArray();
  }

  @Override
  public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    throw new IllegalStateException("Cần viết migration giữ dữ liệu trước khi tăng phiên bản");
  }

  public static final class Entry {
    public final long id, amount;
    public final int category;
    public final String kind, day, note;

    Entry(Cursor c) {
      id = c.getLong(0);
      kind = c.getString(1);
      amount = c.getLong(2);
      category = c.getInt(3);
      day = c.getString(4);
      note = c.getString(5);
    }
  }

  public static final class Month {
    public final List<Entry> entries = new ArrayList<>();
    public String report;
  }

  public long save(long id, String kind, String amountText, int category, String day, String note) {
    if (!kind.equals("IN") && !kind.equals("OUT"))
      throw new IllegalArgumentException("Loại thu/chi sai");
    long amount = Long.parseLong(amountText);
    if (amount <= 0 || amount > 1000000000000L)
      throw new IllegalArgumentException("Số tiền phải từ 1 đến 1.000.000.000.000 đồng");
    if (!LocalDate.parse(day).toString().equals(day))
      throw new IllegalArgumentException("Ngày phải có dạng yyyy-MM-dd");
    if (note.length() > 500) throw new IllegalArgumentException("Ghi chú tối đa 500 ký tự");
    ContentValues v = new ContentValues();
    v.put("kind", kind);
    v.put("amount", amount);
    v.put("category_id", category);
    v.put("day", day);
    v.put("note", note);
    SQLiteDatabase db = getWritableDatabase();
    if (id == 0) return db.insertOrThrow("transactions", null, v);
    if (db.update("transactions", v, "id=?", new String[] {"" + id}) != 1)
      throw new IllegalArgumentException("Bản ghi không còn tồn tại");
    return id;
  }

  public void remove(long id) {
    if (getWritableDatabase().delete("transactions", "id=?", new String[] {"" + id}) != 1)
      throw new IllegalArgumentException("Không tìm thấy bản ghi");
  }

  public void budget(int category, String month, String amount) {
    String normalized = YearMonth.parse(month).toString();
    if (!normalized.equals(month)) throw new IllegalArgumentException("Tháng yyyy-MM");
    long limit = Long.parseLong(amount);
    if (limit <= 0 || limit > 1000000000000L)
      throw new IllegalArgumentException("Hạn mức ngoài phạm vi");
    ContentValues v = new ContentValues();
    v.put("category_id", category);
    v.put("month", month);
    v.put("limit_vnd", limit);
    getWritableDatabase().insertWithOnConflict("budgets", null, v, SQLiteDatabase.CONFLICT_REPLACE);
  }

  public Month month(String text) {
    YearMonth ym = YearMonth.parse(text);
    String from = ym.atDay(1).toString(), to = ym.plusMonths(1).atDay(1).toString();
    SQLiteDatabase db = getReadableDatabase();
    Month result = new Month();
    try (Cursor c = db.rawQuery(ExpenseSql.TOTALS, new String[] {from, to})) {
      c.moveToFirst();
      long income = c.getLong(0), outcome = c.getLong(1);
      result.report =
          "Thu: "
              + income
              + " đ · Chi: "
              + outcome
              + " đ\nChênh lệch thu − chi trong tháng: "
              + (income - outcome)
              + " đ";
    }
    try (Cursor c = db.rawQuery(ExpenseSql.BUDGETS, new String[] {from, to, text})) {
      while (c.moveToNext()) {
        long limit = c.getLong(1), used = c.getLong(2);
        result.report +=
            "\n"
                + c.getString(0)
                + ": "
                + used
                + " / "
                + limit
                + " đ"
                + (used > limit ? " · VƯỢT NGÂN SÁCH" : "");
      }
    }
    try (Cursor c =
        db.rawQuery(
            "SELECT id,kind,amount,category_id,day,note FROM transactions WHERE day>=? AND day<?"
                + " ORDER BY day DESC,id DESC LIMIT 100",
            new String[] {from, to})) {
      while (c.moveToNext()) result.entries.add(new Entry(c));
    }
    return result;
  }
}

Trong ZIP, ExpenseSql chứa đúng các chuỗi truy vấn báo cáo; ExpenseActivity dựng biểu mẫu và danh sách; DbActivity thực hiện tác vụ ở ExecutorService. Giao diện giữ biểu mẫu và id chọn qua Bundle khi xoay. Không đưa đối tượng Cursor sang main thread: đọc thành Entry/Month trong worker, đóng Cursor bằng try-with-resources rồi mới cập nhật màn hình.

8. Kiểm thử, giới hạn và hướng phát triển

Chạy python tests/check_sql.py: bộ thử đọc cùng schema và hằng truy vấn của app, kiểm tra tháng rỗng, hai biên tháng, tiền sai phạm vi, kiểu tiền phân số, khóa ngoại, ghi chú có ký tự đặc biệt, ngân sách và sửa/xóa. Đây là kiểm tra SQLite ở máy phát triển, chưa thay thế kiểm thử Android SQLite trên thiết bị. Chạy thêm thao tác lưu, đóng app, mở lại và xác nhận dữ liệu vẫn còn.

Bản mẫu chưa có sao lưu, đồng bộ hay mã hóa DB; gỡ cài đặt sẽ xóa dữ liệu. Không dùng sổ mẫu làm bản lưu duy nhất cho thông tin quan trọng. Bài tập tiếp: thêm nhiều ví với chuyển khoản hai vế trong một transaction; thêm xuất/nhập JSON có phiên bản; viết migration thêm category do người dùng tạo. Với chuyển khoản, dựng ca cố ý thất bại sau khi trừ ví gửi để chứng minh rollback giữ cả hai số dư.

Bản 1.1 đã dựng APK debug bằng bộ công cụ Android đầy đủ; lint không có lỗi. Dự án này đã qua 6 ca JUnit/Robolectric, gồm mở màn hình, pause/resume và tái tạo Activity trên Android API 35 mô phỏng trong JVM. Hai dự án quản lý còn kiểm thử trực tiếp SQLiteOpenHelper với SQLite native. Chưa kiểm tra trên điện thoại thật; hãy chạy thêm checklist thiết bị trong README, nhất là thao tác cảm ứng, cỡ chữ và độ trễ AI.

Đọc thêm tài liệu gốc: bộ công cụ AGP 8.6, vẽ Custom View với Canvas và SQLite trên Android. Các lớp và dữ liệu trong bài được xây dựng cho thực hành, không phải mã của các tổ chức được dẫn nguồn.

9. Chạy lại kiểm thử và dựng APK

Mở Terminal tại thư mục dự án. Trên Windows chạy lệnh dưới; trên Linux/macOS thay gradlew.bat bằng ./gradlew. assembleDebug tạo APK; lintDebug kiểm tra tĩnh; testDebugUnitTest chạy các ca JUnit và Robolectric. Ba việc này bổ sung cho nhau, không đồng nghĩa đã thử trên mọi điện thoại.

gradlew.bat assembleDebug lintDebug testDebugUnitTest
# APK: app/build/outputs/apk/debug/app-debug.apk
# Test: app/build/reports/tests/testDebugUnitTest/index.html

ZIP có đầy đủ mã kiểm thử trong app/src/test/java và kiểm tra logic/SQL ở thư mục tests. Chỉnh một luật hoặc một truy vấn, chạy lại bộ ca liên quan trước khi dựng APK mới. Thư viện kiểm thử chỉ dùng lúc phát triển, không được đóng vào ứng dụng. APK cung cấp trên trang là bản debug học tập.