Hôm trước tôi làm form khảo sát những chủ đề bài tập lớn các môn chuyên ngành mà anh em sinh viên bây giờ thường gặp phải, thì trong đó có 1 bạn sinh viên năm 3 cung cấp thông tin rất hữu ích về việc bản thân bạn ấy và những người bạn của bạn ấy gặp khá nhiều khó khăn khi làm Bài tập lớn môn Lập trình ứng dụng mobile, vì chưa biết các kỹ thuật để xây dựng cấu trúc cho 1 project demo ứng dụng mobile là như thế nào.
Bản thân tôi thời sinh viên cũng gặp rất nhiều khó khăn với môn học này khi không biết phải bắt đầu như thế nào, nên tôi hiểu những khó khăn mà anh em đang trải qua. Vậy nên tôi cũng lọ mọ code 1 mini project minh họa bài toán bạn ấy đang gặp phải.
Trong project này tôi áp dụng một kiến trúc rất phổ biến trong các dự án thực tế trên công ty, đó là: Clean Architecture. Bên cạnh đó thì trong phần UI của ứng dụng tôi cũng áp dụng MVVM pattern để xây dựng.
Đương nhiên là 1 mini project của 1 Bài tập lớn trên trường Đại học thì phạm vi và độ phức tạp sẽ không quá cao, phù hợp để anh em tham khảo và dễ dàng hiểu được.
Đây là bài viết hướng dẫn chi tiết để giúp quá trình tham khảo code của anh em được dễ hiểu hơn.
Bước 0: Xác định công nghệ và công cụ cần sử dụng
- Ngôn ngữ lập trình và runtime: C# / .NET 8
- Framework: .NET MAUI
- IDE: Visual Studio Community 2022
Bước 1. Xác định mục tiêu
Ở trong form khảo sát thì bạn sinh viên năm 3 mô tả cũng tương đối rõ ràng rồi.
Mục tiêu của bạn ấy là xây dựng 1 ứng dụng mobile sử dụng C# và .NET MAUI để người dùng quản lý và thống kê chi tiêu cá nhân theo ngày, tháng, năm.
Bước 2. Liệt kê các chức năng chính
- Thêm/sửa/xóa chi tiêu hàng ngày.
- Xem thống kê chi tiêu theo ngày/tháng/năm.
- Hiển thị biểu đồ cột hoặc biểu đồ tròn.
- Thống kê các loại chi tiêu chiếm nhiều tiền từ bé đến lớn.
Bước 3. Thiết kế kiến trúc cơ bản của hệ thống
Ở đây chúng ta có thể áp dụng một kiến trúc rất phổ biến trong các dự án thực tế, đó là Clean Architecture (Các bạn có thể đọc thêm về kiến trúc này tại đây: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
Ứng dụng của này có thể chia thành 4 layer như sau:
- Presentation Layer: Nói đơn giản thì phần này chính là phần chúng ta dùng các file
.xaml
và.xaml.cs
để code giao diện (UI) => Lát nữa khi tạo solution tôi sẽ đặt tên project này làExpenseTracker.Maui
- Application Layer: Phần này chứa các use case, hay nhiều người gọi là các service để xử lý business logic => Lát nữa khi tạo solution tôi sẽ đặt tên project này là
ExpenseTracker.Applications
(Lý do riêng project này đặt tên số nhiều chỉ là vì nếu đặtApplication
số ít nó bị trùng tên với 1 class trong namespaceMicrosoft.Maui.Controls
, đặt trùng vẫn có thể fix lỗi bằng cách chỉ định cả namespace nữa) - Domain Layer: Phần này chúng ta sẽ tạo các file entity, interface, constant, enum, ... cần thiết cho ứng dụng> Layer này sẽ không phụ thuộc vào bất kỳ layer nào khác, mà các layer khác sẽ reference đến Domain Layer để có thể sử dụng được các entity của ứng dụng => Lát nữa khi tạo solution tôi sẽ đặt tên project này là
ExpenseTracker.Domain
- Infrastructure Layer: Phần này chúng ta sẽ tạo các file để kết nối với Database (ví dụ có thể sử dụng SQLite), các hàm trong file đó được implement dựa trên quy định của interface trong Domain Layer => Lát nữa khi tạo solution tôi sẽ đặt tên project này là
ExpenseTracker.Infrastructure
Vậy sẽ có câu hỏi đặt ra là: Làm sao để tạo được các "phần" trên ở trong source code của mình?
=> Chúng ta cần phân biệt được khái niệm Solution và Project khi sử dụng Visual Studio.
Thông thường khi tạo 1 solution trong Visual Studio, anh em sẽ thấy nó mặc định chỉ có 1 project bên trong thôi, và các bạn sinh viên khi mới học sẽ thường code hết vào trong 1 project đó.
Nhưng khi làm dự án thực tế, để tách riêng các thành phần như UI, Business Logic, Data Access, ... thì sẽ tách nó ra thành nhiều project khác nhau. Mỗi project đảm nhiệm một chức năng. Cái này khi đọc các sách chuyên ngành các bạn sẽ thấy họ nhắc đến thuật ngữ gọi là Separation of Concerns (Tách biệt các mối quan tâm)
Và để có sự liên kết giữa các project thì tất cả những project đó sẽ nằm trong cùng 1 solution và có reference tới nhau.
Từ ý tưởng đó, chúng ta sẽ tạo 4 project trong solution tương ứng với 4 thành phần tôi nói ở trên. Và để cho tổng quát thì solution tôi sẽ đặt tên là ExpenseTracker
, thể hiện đây là solution dành cho dự án theo dõi chi tiêu.
Do đó khi mới tạo project anh em nhớ sửa tên solution như sau:
Tuy nhiên lưu ý có 1 điều đặc biệt, đó là khi tạo project trong Visual Studio nó có sẵn nhiều template, anh em có thể chọn loại template cho từng project như tôi dưới đây:
-
Presentation Layer: Anh em chọn loại
.NET MAUI App
(cái này khi các bạn tạo solution lúc đầu đã phải chọn để có thể làm app .NET MAUI App rồi) -
3 project còn lại là Application Layer, Domain Layer và Infrastructure Layer: Anh em chọn loại
.NET MAUI Class Library
Tiếp theo là cách để các bạn có thể tạo reference giữa các project:
-
Đầu tiên, nhấn chuột phải vào phần
Dependencies
của mỗi project, sau đó chọnAdd Project Reference...
-
Sau đó tick chọn project cần reference tới và nhấn
OK
Các project cần reference tới nhau như sau:
ExpenseTracker.Domain
sẽ không reference tới đâu cả.ExpenseTracker.Applications
sẽ reference tớiExpenseTracker.Domain
.ExpenseTracker.Infrastructure
sẽ reference tớiExpenseTracker.Domain
.ExpenseTracker.Maui
sẽ reference tớiExpenseTracker.Domain
,ExpenseTracker.Applications
vàExpenseTracker.Infrastructure
.
Bước 4. Thiết kế database
Sau khi tạo được bộ khung kiến trúc ứng dụng rồi thì chúng ta cần bắt tay vào thiết kế database để lưu trữ dữ liệu của app.
Ở đây để cho đơn giản thì chúng ta có thể sử dụng SQLite để lưu trữ cục bộ trên điện thoại luôn. Hiểu đơn giản thì nó giống như app Zola hay bị chửi vì lưu dữ liệu trên máy người dùng ấy. Nhược điểm đương nhiên là nặng máy vì tốn dung lượng bộ nhớ trên máy của người dùng và khi chuyển sang máy khác thì không thấy dữ liệu như khi dùng máy cũ nữa.
Nhưng ở ứng dụng trong bài tập lớn của sinh viên năm 3 thì mình tạm dùng SQLite cũng được. Sau này làm dự án thực tế thì các bạn có thể tìm hiểu cách tạo database server trên cloud thay vì lưu trong máy người dùng.
Với ứng dụng đơn giản này thì chỉ cần 1 bảng chính để lưu thông tin về mỗi lượt chi tiêu.
Tôi sẽ đặt tên bảng này là Expense
(nghĩa là Chi tiêu). Và để cho đơn giản thì chúng ta sẽ dùng Entity Framework Core
để làm việc với SQLite.
Khi đi làm thì anh em Dev sẽ thường đặt tên tiếng Anh thay vì tiếng Việt, tại vì mấy lý do sau:
-
Tiếng Anh là ngôn ngữ chung trong lập trình, hầu hết technical document của các ngôn ngữ lập trình, tài liệu hướng dẫn, code mẫu, thư viện, framework,... đều viết bằng tiếng Anh.
-
Nếu đặt tên hàm, tên biến bằng tiếng Việt có dấu thì khi code có thể sẽ bị lỗi font, lỗi encode, nhất là khi chạy trên hệ thống khác hoặc trên server.
-
Còn nếu dùng tiếng Việt không dấu thì đôi khi có những từ tiếng Việt lúc bỏ dấu đi sẽ khá khó hiểu đúng ý nghĩa, ví dụ:
lon
nghĩa làlợn
hay ***ban
nghĩa làbạn
haybàn
cam
nghĩa làcấm
haycầm
hayquả cam
- ...
=> Vậy nên anh em cũng nên chịu khó học tiếng Anh và tập làm quen với việc đặt tên biến, tên hàm, ... bằng tiếng Anh, để sau này đi làm đỡ bỡ ngỡ.
Ví dụ ở đây tôi sẽ tạo 1 entity Expense
(nghĩa là Chi tiêu)
Tên thuộc tính | Kiểu dữ liệu (trong .NET) | Mô tả |
---|---|---|
Id |
Guid |
Mã định danh cho mỗi bản ghi trong bảng |
Date |
DateTime |
Ngày chi tiêu |
Category |
Category (đây là 1 enum mình tự định nghĩa) |
Danh mục chi tiêu. Ví dụ: 0 là Mua sắm, 1 là Chi phí di chuyển, 2 là Ăn uống, ... Lý do nên lưu dữ liệu này ở dạng số là để sau này nếu anh em muốn bổ sung chức năng hiển thị đa ngôn ngữ (tiếng Việt, tiếng Anh, tiếng Trung, ...) |
Amount |
decimal |
Số tiền chi tiêu |
Description |
string? |
Ghi chú mô tả về khoản chi tiêu đó (tùy chọn người dùng có thể nhập ghi chú hoặc không) |
Bước 5. Code project ExpenseTracker.Domain
Sau khi thiết kế DB rồi thì mình sẽ quay lại code để code project ExpenseTracker.Domain
. Vì project này gồm các entity, interface, constant, enum, ... Đây là những thứ mà các project khác đều cần sử dụng tới nên chúng ta phải code project này trước.
Trong ứng dụng này tôi sẽ tạo:
-
1 thư mục
Entities
, trong này có file entity làExpense.cs
để code classExpense
gồm các thuộc tính của 1 chi tiêu. Ví dụ:using System; using ExpenseTracker.Domain.Enums; namespace ExpenseTracker.Domain.Entities { public class Expense { /// <summary> /// Mã định danh cho mỗi bản ghi trong bảng /// </summary> public Guid Id { get; set; } /// <summary> /// Ngày chi tiêu /// </summary> public DateTime Date { get; set; } /// <summary> /// Danh mục chi tiêu /// </summary> public Category Category { get; set; } /// <summary> /// Số tiền chi tiêu /// </summary> public decimal Amount { get; set; } /// <summary> /// Ghi chú /// </summary> public string? Description { get; set; } /// <summary> /// Hàm khởi tạo /// </summary> public Expense() { Id = Guid.NewGuid(); Date = DateTime.Now; } } }
-
1 thư mục
Enums
, trong đó có fileCategory.cs
để định nghĩa các danh mục chi tiêu. Ví dụ:using System; namespace ExpenseTracker.Domain.Enums { /// <summary> /// Định nghĩa danh mục chi tiêu /// </summary> public enum Category { /// <summary> /// Mua sắm /// </summary> Shopping = 0, /// <summary> /// Chi phí di chuyển /// </summary> Transportation = 1, /// <summary> /// Ăn uống /// </summary> Dining = 2, /// <summary> /// Học tập /// </summary> Studying = 3, /// <summary> /// Giải trí /// </summary> Entertainment = 4, /// <summary> /// Sức khỏe /// </summary> Health = 5, /// <summary> ///Khác /// </summary> Other = 6 } }
-
1 thư mục
Interfaces
, trong đó có file interface làIExpenseRepository.cs
để định nghĩa các hàm mà sau này bên projectExpenseTracker.Infrastructure
sẽ implement. Ví dụ:using ExpenseTracker.Domain.Entities; using ExpenseTracker.Domain.Enums; namespace ExpenseTracker.Domain.Interfaces { /// <summary> /// Interface định nghĩa các phương thức để thao tác với dữ liệu chi tiêu /// </summary> public interface IExpenseRepository { /// <summary> /// Lấy thông tin chi tiêu theo Id /// </summary> /// <param name="id">Id của chi tiêu cần tìm</param> /// <returns>Chi tiêu tương ứng với Id</returns> Task<Expense> GetByIdAsync(Guid id); /// <summary> /// Lấy danh sách tất cả các chi tiêu /// </summary> /// <returns>Danh sách tất cả chi tiêu</returns> Task<IEnumerable<Expense>> GetAllAsync(); /// <summary> /// Lấy danh sách chi tiêu trong khoảng thời gian /// </summary> /// <param name="startDate">Ngày bắt đầu</param> /// <param name="endDate">Ngày kết thúc</param> /// <returns>Danh sách chi tiêu được sắp xếp theo ngày giảm dần</returns> Task<IEnumerable<Expense>> GetByDateRangeAsync(DateTime startDate, DateTime endDate); /// <summary> /// Lấy danh sách chi tiêu theo danh mục /// </summary> /// <param name="category">Danh mục cần tìm</param> /// <returns>Danh sách chi tiêu thuộc danh mục được chọn</returns> Task<IEnumerable<Expense>> GetByCategoryAsync(Category category); /// <summary> /// Thêm mới một chi tiêu /// </summary> /// <param name="expense">Chi tiêu cần thêm</param> /// <returns>Chi tiêu đã được thêm vào database</returns> Task<Expense> AddAsync(Expense expense); /// <summary> /// Cập nhật thông tin chi tiêu /// </summary> /// <param name="expense">Chi tiêu cần cập nhật</param> Task UpdateAsync(Expense expense); /// <summary> /// Xóa chi tiêu theo Id /// </summary> /// <param name="id">Id của chi tiêu cần xóa</param> Task DeleteAsync(Guid id); /// <summary> /// Tính tổng số tiền chi tiêu trong khoảng thời gian /// </summary> /// <param name="startDate">Ngày bắt đầu</param> /// <param name="endDate">Ngày kết thúc</param> /// <returns>Tổng số tiền chi tiêu</returns> Task<decimal> GetTotalAmountByDateRangeAsync(DateTime startDate, DateTime endDate); /// <summary> /// Tính tổng chi tiêu theo từng danh mục trong khoảng thời gian /// </summary> /// <param name="startDate">Ngày bắt đầu</param> /// <param name="endDate">Ngày kết thúc</param> /// <returns>Dictionary chứa tổng chi tiêu của mỗi danh mục</returns> Task<Dictionary<Category, decimal>> GetCategoryTotalsAsync(DateTime startDate, DateTime endDate); } }
Bước 6. Code phần kết nối và query DB (project ExpenseTracker.Infrastructure trong solution)
Tiếp đến là code project ExpenseTracker.Infrastructure
để connect và query dữ liệu tới DB.
Trong project này tôi sẽ tạo:
-
1 thư mục
Data
, trong đó có fileExpenseDbContext.cs
để sử dụng EntityFrameworkCore để tự động mapping giữa entity và bảng trong SQLite. Ở đây tôi sẽ đánh index cho trườngDate
vì sau này làm phần thống kê sẽ phải query theo trường này nhiều.using ExpenseTracker.Domain.Entities; using Microsoft.EntityFrameworkCore; namespace ExpenseTracker.Infrastructure.Data { /// <summary> /// Lớp context để tương tác với cơ sở dữ liệu. Kế thừa từ DbContext của Entity Framework Core /// </summary> public class ExpenseDbContext : DbContext { /// <summary> /// DbSet đại diện cho bảng Expense trong cơ sở dữ liệu /// </summary> public DbSet<Expense> Expense { get; set; } /// <summary> /// Constructor nhận vào các tùy chọn kết nối database. Tự động tạo database nếu chưa tồn tại /// </summary> public ExpenseDbContext(DbContextOptions<ExpenseDbContext> options) : base(options) { Database.EnsureCreated(); } /// <summary> /// Hàm cấu hình model khi tạo database /// </summary> protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); // Tạo index cho cột Date để tối ưu tìm kiếm modelBuilder.Entity<Expense>().HasIndex(e => e.Date); // Cấu hình độ chính xác của cột Amount là 18 chữ số với 2 số thập phân modelBuilder.Entity<Expense>().Property(e => e.Amount).HasPrecision(18,2); } } }
-
1 thư mục
Repositories
, trong đó có fileExpenseRepository.cs
để viết các hàm CRUD để query tới SQLite.using ExpenseTracker.Domain.Entities; using ExpenseTracker.Domain.Enums; using ExpenseTracker.Domain.Interfaces; using ExpenseTracker.Infrastructure.Data; using Microsoft.EntityFrameworkCore; namespace ExpenseTracker.Infrastructure.Repository { /// <summary> /// Repository xử lý các truy vấn dữ liệu liên quan đến chi tiêu /// </summary> public class ExpenseRepository(ExpenseDbContext context) : IExpenseRepository { private readonly ExpenseDbContext _context = context; /// <summary> /// Lấy thông tin chi tiêu theo Id /// </summary> /// <param name="id">Id của chi tiêu cần tìm</param> /// <returns>Chi tiêu tương ứng với Id</returns> public async Task<Expense> GetByIdAsync(Guid id) { return await _context.Expense.FindAsync(id); } /// <summary> /// Lấy danh sách tất cả các chi tiêu /// </summary> /// <returns>Danh sách chi tiêu</returns> public async Task<IEnumerable<Expense>> GetAllAsync() { return await _context.Expense.ToListAsync(); } /// <summary> /// Lấy danh sách chi tiêu trong khoảng thời gian /// </summary> /// <param name="startDate">Ngày bắt đầu</param> /// <param name="endDate">Ngày kết thúc</param> /// <returns>Danh sách chi tiêu được sắp xếp theo ngày giảm dần</returns> public async Task<IEnumerable<Expense>> GetByDateRangeAsync(DateTime startDate, DateTime endDate) { return await _context.Expense .Where(e => e.Date >= startDate && e.Date <= endDate) .OrderByDescending(e => e.Date) .ToListAsync(); } /// <summary> /// Lấy danh sách chi tiêu theo danh mục /// </summary> /// <param name="category">Danh mục cần tìm</param> /// <returns>Danh sách chi tiêu thuộc danh mục được chọn</returns> public async Task<IEnumerable<Expense>> GetByCategoryAsync(Category category) { return await _context.Expense .Where(e => e.Category == category) .OrderByDescending(e => e.Date) .ToListAsync(); } /// <summary> /// Thêm mới một chi tiêu /// </summary> /// <param name="expense">Chi tiêu cần thêm</param> /// <returns>Chi tiêu đã được thêm vào database</returns> public async Task<Expense> AddAsync(Expense expense) { await _context.Expense.AddAsync(expense); await _context.SaveChangesAsync(); return expense; } /// <summary> /// Cập nhật thông tin chi tiêu /// </summary> /// <param name="expense">Chi tiêu cần cập nhật</param> public async Task UpdateAsync(Expense expense) { _context.Expense.Update(expense); await _context.SaveChangesAsync(); } /// <summary> /// Xóa chi tiêu theo Id /// </summary> /// <param name="id">Id của chi tiêu cần xóa</param> public async Task DeleteAsync(Guid id) { var expense = await GetByIdAsync(id); if (expense != null) { _context.Expense.Remove(expense); await _context.SaveChangesAsync(); } } /// <summary> /// Tính tổng số tiền chi tiêu trong khoảng thời gian /// </summary> /// <param name="startDate">Ngày bắt đầu</param> /// <param name="endDate">Ngày kết thúc</param> /// <returns>Tổng số tiền chi tiêu</returns> public async Task<decimal> GetTotalAmountByDateRangeAsync(DateTime startDate, DateTime endDate) { return await _context.Expense .Where(e => e.Date >= startDate && e.Date <= endDate) .SumAsync(e => e.Amount); } /// <summary> /// Tính tổng chi tiêu theo từng danh mục trong khoảng thời gian /// </summary> /// <param name="startDate">Ngày bắt đầu</param> /// <param name="endDate">Ngày kết thúc</param> /// <returns>Dictionary chứa tổng chi tiêu của mỗi danh mục</returns> public async Task<Dictionary<Category, decimal>> GetCategoryTotalsAsync(DateTime startDate, DateTime endDate) { return await _context.Expense .Where(e => e.Date >= startDate && e.Date <= endDate) .GroupBy(e => e.Category) .ToDictionaryAsync( g => g.Key, g => g.Sum(e => e.Amount) ); } } }
Lưu ý là class ExpenseRepository
trong file ExpenseRepository.cs
sẽ implement interface IExpenseRepository
, interface này đã được định nghĩa trong project ExpenseTracker.Domain
ở trên đó. Để sau này nếu muốn chuyển sang DB khác thì chúng ta chỉ cần tạo 1 class khác và cũng implement interface IExpenseRepository
. Còn các service sẽ không bị thay đổi logic. Ngoài ra nó cũng giúp chúng ta dễ dàng mock test khi viết unit test. Anh em có thể tìm hiểu thêm về Dependency Injection (https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection)
Tiếp đến chúng ta cần chạy 2 câu lệnh trong terminal để thực hiện migration.
Vì Entity Framework Core sử dụng Code First, nghĩa là chúng ta sẽ code class entity trong C# trước. Sau đó chạy lệnh migration thì Entity Framework Core sẽ tự tạo ra cấu trúc cơ sở dữ liệu tương ứng.
LƯU Ý:
- Database sẽ không tự cập nhật cấu trúc khi bạn sửa class entity trong code C# (ví dụ thêm/sửa property). Do đó, khi đó chúng ta cũng cần thực hiện câu lệnh migration để:
- Tạo ra file mô tả thay đổi (migrating step).
- Áp dụng thay đổi đó vào DB.
- Điều kiện cần để chạy Migration:
- Project đã cài
Microsoft.EntityFrameworkCore
,Microsoft.EntityFrameworkCore.Design
và provider tương ứng (ví dụ:Microsoft.EntityFrameworkCore.Sqlite
)- Đã cấu hình
DbContext
vàOnModelCreating
DbContext
đã được đăng ký trong DI container
Để thực hiện migration lần đầu tiên, chúng ta sẽ:
-
Cài đặt dotnet-ef tools trước (nếu chưa cài):
dotnet tool install --global dotnet-ef
-
Sau đó chạy lệnh migration để tạo cấu trúc database:
dotnet ef migrations add <Tên Migration> --project
Ví dụ:
dotnet ef migrations add InitialCreate --project
-
Áp dụng Migration vào DB (Lệnh này sẽ chạy tất cả migration chưa áp dụng và cập nhật DB):
dotnet ef database update
Bước 7. Code project ExpenseTracker.Applications
Ở project này tôi sẽ tạo:
-
1 thư mục
Interfaces
, trong đó code 1 fileIExpenseService.cs
để định nghĩa các hàm mà fileExpenseService.cs
sẽ implement. Ví dụ:using ExpenseTracker.Domain.Entities; namespace ExpenseTracker.Applications.Interfaces { /// <summary> /// Interface định nghĩa các business logic liên quan đến quản lý chi tiêu /// </summary> public interface IExpenseService { /// <summary> /// Lấy thông tin chi tiêu theo Id /// </summary> /// <param name="id">Id của chi tiêu cần tìm</param> /// <returns>Chi tiêu tương ứng với Id</returns> Task<Expense> GetExpenseByIdAsync(Guid id); /// <summary> /// Lấy danh sách chi tiêu trong khoảng thời gian /// </summary> /// <param name="startDate">Ngày bắt đầu</param> /// <param name="endDate">Ngày kết thúc</param> /// <returns>Danh sách chi tiêu được sắp xếp theo ngày giảm dần</returns> Task<IEnumerable<Expense>> GetExpensesAsync(DateTime startDate, DateTime endDate); /// <summary> /// Thêm mới một chi tiêu /// </summary> /// <param name="expense">Chi tiêu cần thêm</param> /// <returns>Chi tiêu đã được thêm vào database</returns> /// <exception cref="ArgumentNullException">Thrown khi expense null</exception> Task<Expense> AddExpenseAsync(Expense expense); /// <summary> /// Cập nhật thông tin chi tiêu /// </summary> /// <param name="expense">Chi tiêu cần cập nhật</param> /// <returns>Chi tiêu đã được cập nhật</returns> /// <exception cref="ArgumentNullException">Thrown khi expense null</exception> Task<Expense> UpdateExpenseAsync(Expense expense); /// <summary> /// Xóa chi tiêu theo Id /// </summary> /// <param name="id">Id của chi tiêu cần xóa</param> /// <returns>Task hoàn thành</returns> Task DeleteExpenseAsync(Guid id); /// <summary> /// Tính tổng số tiền chi tiêu /// </summary> /// <param name="expenses">Danh sách chi tiêu cần tính tổng</param> /// <returns>Tổng số tiền chi tiêu</returns> Task<decimal> CalculateTotalAmountAsync(IEnumerable<Expense> expenses); } }
-
1 thư mục
Services
, trong đó code 1 fileExpenseService.cs
using ExpenseTracker.Applications.Interfaces; using ExpenseTracker.Domain.Entities; using ExpenseTracker.Domain.Interfaces; namespace ExpenseTracker.Applications.Services { /// <summary> /// Service xử lý các business logic liên quan đến quản lý chi tiêu /// </summary> public class ExpenseService : IExpenseService { private readonly IExpenseRepository _expenseRepository; /// <summary> /// Khởi tạo service với repository được inject /// </summary> public ExpenseService(IExpenseRepository expenseRepository) { _expenseRepository = expenseRepository; } /// <summary> /// Lấy thông tin chi tiêu theo Id /// </summary> /// <param name="id">Id của chi tiêu cần tìm</param> /// <returns>Chi tiêu tương ứng với Id</returns> public async Task<Expense> GetExpenseByIdAsync(Guid id) { return await _expenseRepository.GetByIdAsync(id); } /// <summary> /// Lấy danh sách chi tiêu trong khoảng thời gian /// </summary> /// <param name="startDate">Ngày bắt đầu</param> /// <param name="endDate">Ngày kết thúc</param> /// <returns>Danh sách chi tiêu được sắp xếp theo ngày giảm dần</returns> public async Task<IEnumerable<Expense>> GetExpensesAsync(DateTime startDate, DateTime endDate) { return await _expenseRepository.GetByDateRangeAsync(startDate, endDate); } /// <summary> /// Thêm mới một chi tiêu /// </summary> /// <param name="expense">Chi tiêu cần thêm</param> /// <returns>Chi tiêu đã được thêm vào database</returns> /// <exception cref="ArgumentNullException">Thrown khi expense null</exception> public async Task<Expense> AddExpenseAsync(Expense expense) { ArgumentNullException.ThrowIfNull(expense); await _expenseRepository.AddAsync(expense); return expense; } /// <summary> /// Cập nhật thông tin chi tiêu /// </summary> /// <param name="expense">Chi tiêu cần cập nhật</param> /// <returns>Chi tiêu đã được cập nhật</returns> /// <exception cref="ArgumentNullException">Thrown khi expense null</exception> public async Task<Expense> UpdateExpenseAsync(Expense expense) { ArgumentNullException.ThrowIfNull(expense); await _expenseRepository.UpdateAsync(expense); return expense; } /// <summary> /// Xóa chi tiêu theo Id /// </summary> /// <param name="id">Id của chi tiêu cần xóa</param> /// <returns>Task hoàn thành</returns> public async Task DeleteExpenseAsync(Guid id) { await _expenseRepository.DeleteAsync(id); } /// <summary> /// Tính tổng số tiền chi tiêu /// </summary> /// <param name="expenses">Danh sách chi tiêu cần tính tổng</param> /// <returns>Tổng số tiền chi tiêu</returns> public Task<decimal> CalculateTotalAmountAsync(IEnumerable<Expense> expenses) { return Task.FromResult(expenses.Sum(e => e.Amount)); } } }
Vì yêu cầu của bài tập lớn này rất đơn giản, nên các bạn cũng sẽ thấy các hàm trong ExpenseService
này cũng rất đơn giản, hầu hết chỉ là gọi đến hàm trong repository và trả về kết quả thôi.
Trong các dự án thực tế sau này thì có thể sẽ phức tạp hơn.
Bước 8. Code giao diện (Pretation Layer)
Các dự án thực tế thường sẽ có vị trí Designer đảm nhiệm việc thiết kế UI. Nhưng anh em sinh viên hoặc làm dự án cá nhân không có designer trợ giúp thì các bạn có thể tham khảo thiết kế trên một số trang web như Pinterest, Dribbble, ...
Tôi cũng không biết nhiều về design, nên anh em thấy UI tôi code xấu thì có thể tự xây dựng theo mắt nhìn của mình sao cho đẹp nhé.
Tôi chỉ có lời khuyên là anh em nên tách ra mỗi màn hình trong app sẽ code thành 1 file .xaml
Ví dụ:
- Có file
ExpenseListPage.xaml
để dùng làm giao diện của trang chính, khi bắt đầu vào ứng dụng thì sẽ hiển thị trang này. - File
AddEditExpensePage.xaml
để code giao diện khi thêm hoặc sửa 1 chi tiêu. - File
StatisticsPage.xaml
để code giao diện thống kê, hiển thị các biểu đồ cột, biểu đồ tròn.
Các file .xaml
này tương ứng với phần View trong mô hình MVVM (Model-View-ViewModel)
Khi tạo file .xaml
trong project .NET MAUI trên Visual Studio thì sẽ tự động có kèm thêm 1 file .xaml.cs
, file này được gọi là code-behind file. Đây là file code C# chứa logic liên quan đến trang đó, ví dụ như gán ViewModel, xử lý sự kiện, xử lý điều hướng, ...
Ngoài những file .xaml
và file code-behind (.xaml.cs
) trên, thì chúng ta cũng cần thêm các file C# để xử lý ViewModel trong mô hình MVVM (Model-View-ViewModel).
Một số điểm giúp các bạn phân biệt nên code gì trong file code-behind (.xaml.cs
), nên code gì trong file C# ViewModel (chỉ có .cs
)
Điểm so sánh | .xaml.cs |
ViewModel.cs |
---|---|---|
Biết về giao diện (UI)? | ✅ Có thể truy cập Button , Label , ... |
❌ Không nên biết giao diện |
Có xử lý sự kiện UI không? | ✅ Có thể xử lý sự kiện (Clicked , TextChanged ) |
✅ Có, nhưng thông qua ICommand , không gắn trực tiếp với UI |
Có sử dụng dữ liệu không? | ✅ Có, nhưng chủ yếu gán BindingContext |
✅ Chính là nơi xử lý dữ liệu |
Dễ unit test không? | ❌ Khó test vì gắn chặt UI | ✅ Dễ test vì không phụ thuộc giao diện |
Về vấn đề quản lý string và Localization (chức năng mà có thể thay đổi ngôn ngữ ứng dụng tiếng Anh, tiếng Việt, ...)
Mặc dù trong bài tập lớn này không có yêu cầu làm localization, nhưng tôi vẫn chủ động đưa hết các string vào chung 1 file resource, chứ không fix cứng các string đó trong code.
Cái này là rút kinh nghiệm từ bài học đau đớn khi tôi mới đi làm lúc ra trường.
Khi ấy tôi được giao vào một dự án mới. Mọi thứ được build từ đầu. Team bọn tôi thì toàn anh em mới ra trường chưa có kinh nghiệm gì.
Bọn tôi gần như chưa bao giờ nghĩ đến việc làm localization, nên khi code chỗ nào trên UI cần string là bọn tôi code ở đó luôn.
Đến khi dự án có khách hàng nước ngoài muốn dùng thử, sếp yêu cầu bọn tôi phải bổ sung chức năng chuyển đổi tiếng Anh, tiếng Việt và có thể sau này sẽ thêm một số ngôn ngữ khác nữa.
Vậy là lúc đó chúng tôi mới nhận ra vấn đề. Bắt đầu biết đến localization và phải sửa code rất nhiều, đi tìm từng ngóc ngách đang fix cứng string trong code để chỉnh sửa thì mới áp dụng được localization.
Anh em có thể tìm hiểu thêm về localization trong .NET MAUI ở đây: https://learn.microsoft.com/en-us/dotnet/maui/fundamentals/localization?view=net-maui-8.0
Phần biểu đồ cũng hơi đặc biệt với các anh em sinh viên chưa động đến chức năng này bao giờ, nên tôi cũng có suggest thêm là:
- Anh em nên dùng Biểu đồ cột để thống kê số tiền chi theo từng ngày hoặc từng tháng.
- Còn biểu đồ tròn thì để thống kê theo tỉ lệ % chi tiêu theo danh mục (mua sắm, chi phí di chuyển, ăn uống, ...)
- Anh em có thể sử dụng thư viện Syncfusion.Maui.Charts. Ưu điểm của thư viện này là dễ tích hợp và giao diện đẹp mắt. Nhược điểm là sẽ bị hiển thị thông báo license. Nếu không thích thì anh em có thể mày mò thư viện khác như Microcharts
Trên đây là quá trình tôi lọ mọ code project demo cho bài tập lớn môn Lập trình ứng dụng mobile của một bạn sinh viên năm 3.
Tôi thì code web nhiều hơn là mobile. Nên tôi hi vọng các anh em chuyên làm Mobile Dev nếu có đọc được bài viết này thì cũng góp ý thêm cho tôi học tập, cũng như chia sẻ để các bạn sinh viên sau này muốn theo mảng mobile sẽ có những góc nhìn chính xác hơn nữa.
Anh em nào cần source code thì comment địa chỉ email dưới bài viết rồi tôi gửi nhé. Tôi đang refactor code cho đỡ bẩn cái xong thì mới đẩy lên GitHub. Lúc nào xong thì tôi sẽ gửi mail báo anh em.
Đang trong kỳ MayFest nên nếu bài viết này hữu ích với anh em thì nhờ anh em ủng hộ bằng cách upvote, comment và bookmark bài viết này giúp tôi nhé.
Cảm ơn anh em rất nhiều!