Android Java
Dự án Android quản lý đào tạo: SQLite, đăng ký học phần và điểm
CRUD sinh viên, học phần, lớp mở; kiểm tra sức chứa, xung đột lịch, giao dịch đăng ký và trung bình hệ 10.
Thích và đánh giá Bình luận và hỏi đápCRUD sinh viên, học phần, lớp mở; kiểm tra sức chứa, xung đột lịch, giao dịch đăng ký và trung bình hệ 10. 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. Chọn phạm vi đào tạo đủ rõ để kiểm chứng
Dự án quản lý một học kỳ giả định với sinh viên, học phần, lớp mở, đăng ký và điểm. Mỗi lớp mở có một buổi lặp theo ngày trong tuần, một khoảng phút, một giảng viên và một phòng. App cho CRUD hồ sơ, mở lớp, đăng ký/rút, nhập điểm và xem điểm trung bình có trọng số hệ 10. Dữ liệu tên/mã trong ZIP là dữ liệu bài tập, không phải dữ liệu đào tạo thật của UTT.
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. Từ thực thể tới schema SQLite
students giữ mã và họ tên. courses giữ mã, tên, tín chỉ. sections tham chiếu học phần, chứa sức chứa, thứ trong tuần, phút bắt đầu/kết thúc, mã GV và phòng. enrollments nối sinh viên với lớp mở, có điểm chưa biết hoặc điểm nguyên phần mười. Không lặp tên học phần ở mọi dòng đăng ký; đổi tên một học phần phải có tác dụng qua quan hệ, không sửa hàng trăm bản sao chuỗi.
| Bảng | Khóa/giới hạn chính | Ý nghĩa |
|---|---|---|
| students | code UNIQUE | Mã sinh viên phân biệt hoa/thường |
| courses | credits 1–6 | Tín chỉ của học phần |
| sections | capacity 1–200; end>start | Một buổi tuần trong một học kỳ |
| enrollments | UNIQUE(student_id,section_id) | Không đăng ký trùng cùng lớp |
Khóa ngoại bật trong onConfigure. Xóa hồ sơ đang được đăng ký bị từ chối để người dùng rút đăng ký trước; app không âm thầm xóa điểm theo sinh viên hoặc lớp. Có thể chọn chính sách lưu vết/khóa hồ sơ cho hệ thống thật, nhưng phải quy định rõ trước khi sửa ON DELETE.
3. Thử quy trình dữ liệu từ giao diện
Mở Sinh viên, dùng SV01–SV03 có sẵn hoặc nhập hồ sơ mới. Mở Học phần để xem AND, TEST. Ở Lớp mở & lịch, AND01 diễn ra thứ Hai 480–600 phút, tức 08:00–10:00; TEST01 ở thứ Ba cùng giờ. Sang Đăng ký & điểm, nhập SV01 và AND01, để điểm trống rồi lưu. Chọn lại dòng để nhập 8.0. Nhập tiếp TEST01 với 9.5; khi hai học phần đều 3 tín chỉ, điểm TB là 8.75.
Các mã được dùng làm tham chiếu trong biểu mẫu; mã phải tồn tại trước. Chạm một dòng để điền lại trường, Lưu cập nhật id đã chọn, Bản ghi mới bỏ lựa chọn, Xóa/Rút đăng ký có xác nhận. Bản 1.1 giữ DB đã lưu, tab, nội dung bản nháp và id bản ghi đang sửa khi tái tạo màn hình bằng Bundle. Ca kiểm thử nhập một sinh viên chưa lưu rồi tái tạo Activity xác nhận nội dung vẫn còn; dữ liệu nháp không tự trở thành bản ghi trong DB.
4. Một đăng ký là một giao dịch nghiệp vụ
Không chỉ kiểm tra UNIQUE rồi insert. Quy trình còn cần: tìm sinh viên/lớp, kiểm tra lớp còn chỗ, sinh viên chưa đăng ký học phần ấy ở lớp khác và không có lịch học giao nhau. Tất cả kiểm tra cùng lệnh ghi chạy trong một SQLite transaction. Nếu đọc số chỗ ở ngoài rồi mới ghi, hai yêu cầu có thể đều nhìn thấy chỗ cuối và cùng được chấp nhận khi ứng dụng được mở rộng.
db.beginTransaction();
try {
// Tìm FK, kiểm tra capacity, cùng học phần, trùng lịch.
db.insertOrThrow("enrollments", null, values);
db.setTransactionSuccessful();
} finally {
db.endTransaction(); // Không successful => rollback
}
Khi sửa đăng ký, mã mẫu xóa bản cũ trong transaction, kiểm tra lại rồi chèn bản mới với cùng id. Nếu dữ liệu mới sai, rollback phục hồi bản cũ và điểm cũ. Đây là lý do không thể tách thao tác xóa và thêm thành hai lần lưu độc lập. Bộ thử cố ý chèn điểm 10.1 sau bước xóa để xác nhận dữ liệu cũ còn nguyên khi giao dịch thất bại.
5. Xung đột lịch và ranh giới nửa mở
Hai buổi cùng ngày giao nhau khi startA<endB và endA>startB. Khoảng [480,600) và [600,720) không trùng: buổi trước kết thúc đúng lúc buổi sau bắt đầu. Khoảng [480,600) và [599,720) trùng một phút. Dùng dấu ≤ thay cho < sẽ cấm cả trường hợp sát giờ, dù đặc tả chưa yêu cầu khoảng nghỉ.
SELECT COUNT(*) FROM sections
WHERE id <> ? AND weekday = ?
AND start_min < ? AND end_min > ?
AND (teacher = ? COLLATE NOCASE OR room = ? COLLATE NOCASE);
Khi tạo/sửa lớp, kiểm tra trùng GV hoặc phòng với lớp khác. Khi đăng ký, kiểm tra trùng lịch của sinh viên. Khi sửa giờ một lớp đã có người học, phải kiểm tra lại lịch của mọi sinh viên đã đăng ký, không chỉ GV/phòng. Nếu chuyển lớp sang học phần khác, còn cần phát hiện sinh viên đang học cùng học phần đó ở lớp khác. Một chức năng sửa thường có nhiều ràng buộc hơn chức năng tạo mới.
Bản mẫu dùng mã GV/phòng do người dùng nhập, so không phân biệt chữ hoa/thường ở điều kiện tài nguyên và trim hai đầu. Chưa có bảng chuẩn hóa GV/phòng, hai cơ sở hay thời gian di chuyển. Trong bài nghiên cứu lập lịch nhiều cơ sở, hai buổi sát giờ ở khác cơ sở có thể không khả thi dù không giao nhau; đó là một ràng buộc bổ sung, không nên trộn vào phép overlap cơ bản mà không đổi đặc tả.
6. Điểm chưa có, điểm 0 và trung bình
NULL nghĩa là chưa có điểm, khác điểm 0 đã xác nhận. Điểm lưu grade_tenths từ 0 tới 100; nhập 8.5 thành 85 bằng BigDecimal, từ chối số có quá nhiều chữ số thập phân thay vì làm tròn âm thầm. Trung bình hệ 10 chỉ tính các đăng ký đã có điểm và lấy tín chỉ làm trọng số. Hiển thị hai chữ số là định dạng trình bày, không biến giá trị lưu thành số đã làm tròn.
Công thức mẫu là tổng(grade_tenths×credits)/(10×tổng credits có điểm). Chưa triển khai thang 4, học lại, cải thiện, học phần điều kiện hay quy tắc học vụ chính thức. Không dùng chữ GPA mà thiếu thang điểm và chính sách. Một hệ thống thật phải có phiên bản quy chế để kết quả cũ có thể giải thích lại sau khi quy định thay đổi.
7. Mã dữ liệu và tổ chức tác vụ nền
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.
package vn.kienutt.training;
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 TrainingDb extends SQLiteOpenHelper {
private final Context context;
public TrainingDb(Context c) {
super(c, "training.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 Row {
public final long id;
public final String[] values;
public final String label;
Row(long id, String[] values, String label) {
this.id = id;
this.values = values;
this.label = label;
}
}
private long count(SQLiteDatabase db, String sql, String... args) {
try (Cursor c = db.rawQuery(sql, args)) {
c.moveToFirst();
return c.getLong(0);
}
}
private long id(SQLiteDatabase db, String table, String code) {
try (Cursor c = db.rawQuery("SELECT id FROM " + table + " WHERE code=?", new String[] {code})) {
if (!c.moveToFirst()) throw new IllegalArgumentException("Không tìm thấy mã " + code);
return c.getLong(0);
}
}
private String text(String s) {
s = s.trim();
if (s.isEmpty() || s.length() > 100)
throw new IllegalArgumentException("Không để trống, tối đa 100 ký tự");
return s;
}
private int number(String s, int min, int max) {
int n = Integer.parseInt(s);
if (n < min || n > max) throw new IllegalArgumentException("Số phải từ " + min + " đến " + max);
return n;
}
private void put(SQLiteDatabase db, String table, long id, ContentValues v) {
if (id == 0) db.insertOrThrow(table, null, v);
else if (db.update(table, v, "id=?", new String[] {"" + id}) != 1)
throw new IllegalArgumentException("Bản ghi không tồn tại");
}
public void save(int mode, long selected, String[] raw) {
String[] a = raw.clone();
for (int i = 0; i < a.length; i++) a[i] = a[i].trim();
SQLiteDatabase db = getWritableDatabase();
db.beginTransaction();
try {
ContentValues v = new ContentValues();
if (mode == 0) {
v.put("code", text(a[0]));
v.put("name", text(a[1]));
put(db, "students", selected, v);
} else if (mode == 1) {
v.put("code", text(a[0]));
v.put("name", text(a[1]));
v.put("credits", number(a[2], 1, 6));
put(db, "courses", selected, v);
} else if (mode == 2) {
long course = id(db, "courses", a[1]);
int cap = number(a[2], 1, 200),
weekday = number(a[3], 1, 7),
start = number(a[4], 0, 1439),
end = number(a[5], 1, 1440);
if (end <= start) throw new IllegalArgumentException("Giờ kết thúc phải sau giờ bắt đầu");
String teacher = text(a[6]), room = text(a[7]);
if (count(db, "SELECT COUNT(*) FROM enrollments WHERE section_id=?", "" + selected) > cap)
throw new IllegalArgumentException("Sức chứa nhỏ hơn số đã đăng ký");
if (count(
db,
TrainingSql.RESOURCE_CONFLICT,
"" + selected,
"" + weekday,
"" + end,
"" + start,
teacher,
room)
> 0) throw new IllegalArgumentException("Trùng lịch giảng viên hoặc phòng");
try (Cursor c =
db.rawQuery(
"SELECT student_id FROM enrollments WHERE section_id=?",
new String[] {"" + selected})) {
while (c.moveToNext()) {
String student = c.getString(0);
if (count(
db,
TrainingSql.STUDENT_CONFLICT,
student,
"" + selected,
"" + weekday,
"" + end,
"" + start)
> 0)
throw new IllegalArgumentException("Lịch mới trùng lịch của sinh viên đã đăng ký");
if (count(
db,
"SELECT COUNT(*) FROM enrollments e JOIN sections s ON s.id=e.section_id WHERE"
+ " e.student_id=? AND s.id<>? AND s.course_id=?",
student,
"" + selected,
"" + course)
> 0)
throw new IllegalArgumentException("Sinh viên đã học cùng học phần ở lớp khác");
}
}
v.put("code", text(a[0]));
v.put("course_id", course);
v.put("capacity", cap);
v.put("weekday", weekday);
v.put("start_min", start);
v.put("end_min", end);
v.put("teacher", teacher);
v.put("room", room);
put(db, "sections", selected, v);
} else if (mode == 3) {
if (selected > 0 && db.delete("enrollments", "id=?", new String[] {"" + selected}) != 1)
throw new IllegalArgumentException("Đăng ký không tồn tại");
long student = id(db, "students", a[0]), section = id(db, "sections", a[1]);
long course;
int cap, weekday, start, end;
try (Cursor c =
db.rawQuery(
"SELECT course_id,capacity,weekday,start_min,end_min FROM sections WHERE id=?",
new String[] {"" + section})) {
c.moveToFirst();
course = c.getLong(0);
cap = c.getInt(1);
weekday = c.getInt(2);
start = c.getInt(3);
end = c.getInt(4);
}
if (count(db, "SELECT COUNT(*) FROM enrollments WHERE section_id=?", "" + section) >= cap)
throw new IllegalArgumentException("Lớp đã đủ chỗ");
if (count(db, TrainingSql.SAME_COURSE, "" + student, "" + course) > 0)
throw new IllegalArgumentException("Đã đăng ký học phần này");
if (count(
db,
TrainingSql.STUDENT_CONFLICT,
"" + student,
"" + section,
"" + weekday,
"" + end,
"" + start)
> 0) throw new IllegalArgumentException("Sinh viên trùng lịch học");
if (selected > 0) v.put("id", selected);
v.put("student_id", student);
v.put("section_id", section);
if (a[2].isEmpty()) v.putNull("grade_tenths");
else {
int grade = new java.math.BigDecimal(a[2]).movePointRight(1).intValueExact();
if (grade < 0 || grade > 100)
throw new IllegalArgumentException("Điểm 0 đến 10, tối đa 1 chữ số thập phân");
v.put("grade_tenths", grade);
}
db.insertOrThrow("enrollments", null, v);
} else throw new IllegalArgumentException("Mục không hợp lệ");
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
public void remove(int mode, long selected) {
String[] tables = {"students", "courses", "sections", "enrollments"};
if (mode < 0 || mode > 3) throw new IllegalArgumentException("Mục sai");
if (getWritableDatabase().delete(tables[mode], "id=?", new String[] {"" + selected}) != 1)
throw new IllegalArgumentException("Không tìm thấy bản ghi");
}
public List<Row> list(int mode) {
SQLiteDatabase db = getReadableDatabase();
String[] queries = {
"SELECT id,code,name FROM students ORDER BY code",
"SELECT id,code,name,credits FROM courses ORDER BY code",
"SELECT s.id,s.code,c.code,s.capacity,s.weekday,s.start_min,s.end_min,s.teacher,s.room FROM"
+ " sections s JOIN courses c ON c.id=s.course_id ORDER BY s.code",
"SELECT e.id,st.code,s.code,CASE WHEN e.grade_tenths IS NULL THEN '' ELSE"
+ " printf('%.1f',e.grade_tenths/10.0) END FROM enrollments e JOIN students st ON"
+ " st.id=e.student_id JOIN sections s ON s.id=e.section_id ORDER BY st.code,s.code"
};
List<Row> result = new ArrayList<>();
try (Cursor c = db.rawQuery(queries[mode], null)) {
while (c.moveToNext()) {
String[] values = new String[c.getColumnCount() - 1];
for (int i = 0; i < values.length; i++) values[i] = c.getString(i + 1);
String label = String.join(" · ", values);
if (mode == 0) {
try (Cursor g = db.rawQuery(TrainingSql.GPA, new String[] {c.getString(0)})) {
g.moveToFirst();
label +=
"\nĐiểm TB hệ 10 (đã có điểm): "
+ (g.isNull(0)
? "chưa có"
: String.format(java.util.Locale.ROOT, "%.2f", g.getDouble(0)));
}
}
result.add(new Row(c.getLong(0), values, label));
}
}
return result;
}
}TrainingSql giữ các truy vấn overlap, trùng học phần và điểm TB. TrainingActivity có bốn biểu mẫu; DbActivity chạy mọi truy vấn trên hàng đợi nền, đóng DB sau khi công việc đang xếp hàng hoàn tất và bỏ callback nếu Activity đã hủy. Không ghép tên bảng từ dữ liệu nhập của người: các tên bảng ở đây lấy từ tập cố định của chương trình, còn mã/id truyền bằng tham số.
8. Kế hoạch kiểm thử và mở rộng thành đồ án
Chạy python tests/check_sql.py để kiểm tra cùng schema/truy vấn: lớp đủ chỗ, trùng học phần, xung đột lịch, hai biên 599/600, điểm TB 8.75, điểm ngoài miền, khóa ngoại và rollback. Sau đó kiểm tra trên thiết bị: giảm sức chứa dưới sĩ số, đổi giờ lớp làm sinh viên trùng lịch, xóa học phần còn lớp mở, rồi đóng/mở app. Mỗi thao tác bị từ chối phải giữ dữ liệu trước đó.
Có thể phát triển đồ án theo ba bước: thêm kỳ học và nhiều buổi/tuần; chuẩn hóa GV, phòng, cơ sở rồi xét thời gian đi lại; cuối cùng tách backend với vai trò quản trị, giáo vụ, giảng viên và sinh viên. SQLite cục bộ vẫn hữu ích cho cache nhưng không tự tạo hệ thống nhiều người dùng. Các bài ASP trên website có thể tiếp nhận dữ liệu lớp/phòng đã chuẩn hóa để nghiên cứu sinh lịch; mô-đun hiện tại chỉ quản lý và kiểm tra lịch nhập tay.
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 8 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.