Blog

  • Combine big framework từ iOS 13

    Combine big framework từ iOS 13

    Định nghĩa

    Combine là một framework của Apple được tích hợp từ iOS 13. Combine cung cấp các API cho phép khai báo để xử lý các giá trị theo thời gian. Các giá trị này có thể đại diện cho nhiều loại sự kiện không đồng bộ. Combine cung cấp các Publishers hiển thị các giá trị có thể thay đổi theo thời gian và subscribers nhận các giá trị đó từ Publishers.

    Reactive có nghĩa là lập trình với các luồng giá trị không đồng bộ. Các bạn có thể tìm hiểu thêm về Reactive ở đây

    Functional programming là tất cả các chức năng trong lập trình. Trong Swift, các hàm có thể được truyền dưới dạng đối số cho các hàm khác, được trả về từ các hàm, được lưu trữ trong các biến và cấu trúc dữ liệu và được xây dựng trong thời gian chạy dưới dạng bao đóng.

    Trong style của declarative, bạn mô tả những gì chương trình thực hiện mà không mô tả luồng điều khiển. Theo style imperative, bạn viết cách thức hoạt động của chương trình bằng cách triển khai và xử lý một loạt tác vụ. Các chương trình imperative chủ yếu dựa vào trạng thái, thường được sửa đổi bằng các bài tập.

    Lập trình với Combine là sự kết hợp của declarative, reactive và functional. Nó liên quan đến các chức năng xâu chuỗi và chuyển các giá trị từ cái này sang cái khác. Điều này tạo ra các luồng giá trị, chảy từ đầu vào đến đầu ra.

    Lý thuyết là vậy, nhưng nếu chúng ta bỏ qua hết các định nghĩa thì Combine được thể hiện đơn giản như hình ảnh bên dưới đây:

    Và thệm trí có thể ngắn gọn hơn:
    Combine = Publishers + Subscribers + Operators

    Đến đây có vẻ chúng ta đã hiểu được Combine là gì rồi nhỉ, Tiếp tục đi đến các thành phần khác trong combine nhé.

    Publisher là gì?

    Publisher là gửi chuỗi giá trị theo thời gian đến một hoặc nhiều subscribers

    Combine publishers được tuân thủ theo protocol sau:


    Một publisher có thể gửi giá trị hoặc terminate với thành công hoặc lỗi. Output xác định loại giá trị mà publisher có thể gửi. Failure xác định loại lỗi mà nó có thể thất bại.

    Mothod receive(subscriber:) kết nối subscriber với publisher. Nó xác định hợp đồng: đầu ra của publisher phải khớp với đầu vào của subscriber và các loại lỗi cũng vậy.

    Subscriber là gì?

    Subscriber là để nhận các giá trị từ Publisher



    Một subscriber có thể nhận nhận giá trị với type Input hoặc termination với thành công hoặc lỗi

    các mothods receice mô tả các bước khác nhau trong vòng đời của người đăng ký. Chúng ta sẽ tìm hiểu vòng đời của subscriber này ở phần tiếp theo nhé!

    Kết nối Publisher với Subscriber

    Combine có hai methods được tích hợp sẵn: Subscribers.Sink và Subscribers.Assign. Bạn có thể kết nối chúng bằng cách gọi một trong hai method này bên dưới publisher

    • Sink(receiveCompletion:receiveValue:) để xử lý phần tử mới hoặc sự kiện hoàn thành trong một lần đóng.
    • assign(to:on:) để ghi phần tử mới vào một thuộc tính.


    1. Tạo ra một publisher Just gửi một signle giá trị và sau đó hoàn thành. Combine sẵn một số built-in trong đó có Just
    2. Kết nối publisher với subscriber bằng method sink

    Kết quả in ra lần lượt là:

    1
    finished

    Sau khi send 1 thì publisher tự động kết thúc, ở đây chúng ta không xử lý bất kì error nào, bởi vì Just không bao giờ thật bại.

    Subjects là gì?

    Subject là một lại publisher đặc biệt, nó có thể insert giá trị, truyền được giá trị từ bên ngoài vào. Giao diện của Subject cung cấp ba cách khác nhau để gửi các elements



    Combine có hai chủ thể tích hợp sẵn: PassthroughSubject và CurrentValueSubject.

    Bắt đầu với PassthroughSubject


    1. Tạo ra một passthrough subject. Chúng ta set Failure type là Never để cho nó biết rằng nó luôn kết thúc thành công.
    2. Đăng ký subject(hay nhớ rằng đó vẫn là một publisher).
    3. Gửi hai giá trị tới luồng và sau đó hoàn thành nó.

    Kết quả lần lượt như sau:

    Hello,
    World!
    finished

    Tiếp theo mình đi đến CurrentValueSubject


    1. Tạo ra một subject với giá trị khởi tạo là 1
    2. In ra giá trị hiện tại
    3. Cập nhật giá trị hiện tại thành 2 và in nó ra
    4. Đăng ký publisher

    Kết quả lần lượt như sau:

    1
    2
    2

    Combine Publisher and Subscriber Life Cycle

    Như đề cập ở bên trên, bây giờ chúng ta cùng tìm hiểu về life cycle của Publisher và Subscriber nhé!!!

    Sự kết nối giữ Publisher và Subscriber được gọi là subscription. Thông qua các bước của kết nối như vậy xác định vòng đời của Publisher và Subscriber


    Để ý đến operator print(_:to:) ở trên. Nó in ra các thông báo cho tất cả các sự kiện của Publisher vào console, từ các log được in ra thì chúng ta cũng có thể nắm được một phần nào đó về vòng đời của Publisher và Subscriber. Đây là log được ghi nhận từ console

    Từ log bên trên chúng ta đã có manh mối về vòng đời của publisher-subscriber. Với log ghi được, chúng ta sẽ đi phân tích từ đầu đến cuối nhé

    1. Subscriber kết nối với Publisher bằng cách gọi subscribe(S).

    2. Tạo một đăng ký tới Publisher bằng cách gọi receive(subscriber: S) trên chính nó.

    3. Publisher nhận yêu cầu đăng ký. Nó gọi receive(subscription:) trong subscriber

    4. Subscriber đăng ký một phần tử mà nó muốn nhận, Nó gọi đến request(:) và chuyển Request dưới dạng tham số. Nhu cầu xác định số lượng mục mà publisher có thể gửi cho subsriber thông qua subscription. Trong trường hợp này thì nhu cầu là không giới hạn (unlimited)

    5. Publisher gửi giá trị thông bằng cách gọi receive(_:) trên subscriber. Method này trả về một Demand, cho biết có bao nhiêu items mà subscriber mong muốn nhận được. Subscriber chỉ có thể tăng demand hoặc để nguyên, không thể giảm demand đi

    6. Kết thúc đăng ký với một trong những kết quả sau đây:
    – Cancelled Điều này có thể tự động xảy ra khi publisher được giải phóng, được hiển thị trong ví dụ bên trên. Một cách khác là hủy thủ công: token.cancel().
    – Finish thành công
    – Fail với một error.

    Chaining Publishers với Operators

    Operators là các methods đặc biệt được gọi bên dưới Publisher và trả ra một Publisher khác. Điều này cho phép áp dụng chúng một cách lần lượt, tạo ra một chuỗi. Mỗi operator đều biến đổi publisher cũ và được trả ra từ chính publisher cũ đó.

    Một operator đều tạo mới một publisher. Sau đó, các operators có thể áp dụng lần lượt. Mỗi operator nhận được được tạo ra từ publisher trước đó trong chỗi. Ở đây mình đề cập đến thứ tự tương đối của opetator là ngược dòng, nghĩa là operator liền trước và tiếp theo.

    Bây giờ chúng ta cùng nhau đến một ví dụ thông qua đó có thể xem cách xâu chuỗi operators khi xử lý request HTTP URL với Combine



    1. Tạo một request để tải kho GitHub. Mình đang sử dụng API GitHub REST.
    2. Combine được tích hợp tốt vào Swift system frameworks và SDK iOS. Điều này cho phép chúng ta sử dụng publisher để xử lý các tác vụ dữ liệu URLSession.
    3. Truyền dữ liệu phản hồi. Chúng ta sử dụng toán tử map(_:), biến đổi giá trị ngược dòng từ (data: Data, response: URLResponse) thành Data.
    4. Decode nội dung phản hồi bằng JSONDecoder.
    5. Kết nối sink subscriber. Nó in số lượng kho lưu trữ đã nhận và hoàn thành.

    Và đây là kết quả:

    V8tr has 30 repositories
    finished

    Thông qua bài này mình đã giới thiệu Combine đến các bạn
    Mình hi vọng bài viết có thể giúp ích được cho các bạn. Chúc các bạn thành công!

  • Học lập trình với ngôn ngữ Swift – Bài 1: Chào mừng bạn đến với Swift

    Học lập trình với ngôn ngữ Swift – Bài 1: Chào mừng bạn đến với Swift

    Trước khi bắt đầu thực hiện những dòng code đầu tiên cho ứng dụng của bạn bằng ngôn ngữ lập trình Swift, chúng ta sẽ tìm hiểu qua về nó để biết rằng tại sao chúng ta lại chọn Swift để làm các ứng dụng, nó có những ưu điểm gì? lịch sử hình thành như nào? Xu hướng phát triển ra sao? Liệu nó có đáng để chúng ta tìm hiểu và học hay không?. Vậy các bạn cùng mình tiếp tục theo dõi bài viết này nhé.

    Định nghĩa về Swift

    Swift là một ngôn ngữ lập trình hướng đối tượng dành cho việc phát triển iOS và macOSwatchOStvOS và z/OS. được giới thiệu bởi Apple tại hội nghị WWDC 2014.Swift được mong đợi sẽ tồn tại song song cùng Objective-C, ngôn ngữ lập trình hiện tại dành cho các hệ điều hành của Apple. Swift được thiết kế để hoạt động với các framework Cocoa và Cocoa Touch của Apple và phần lớn mã Objective-C hiện có được viết cho các sản phẩm của Apple. Nó được biên dịch với trình biên dịch LLVM và đã được đưa vào Xcode kể từ phiên bản 6, phát hành năm 2014. Trên các nền tảng của Apple[12], nó sử dụng thư viện runtime Objective-C cho phép mã CObjective-CC++ và Swift cùng chạy trong một chương trình.[13]

    Apple dự định Swift hỗ trợ nhiều khái niệm cốt lõi liên quan đến Objective-C, đáng chú ý là thu hồi động, các ràng buộc phổ thông, lập trình mở rộng và các tính năng tương tự, nhưng theo cách “an toàn hơn”, giúp dễ dàng bắt lỗi phần mềm hơn; Swift có các tính năng giải quyết một số lỗi lập trình phổ biến như con trỏ rỗng cung cấp cú pháp đặc biệt để giúp tránh kim tự tháp diệt vong. Swift hỗ trợ khái niệm về khả năng mở rộng giao thức, một hệ thống mở rộng có thể được áp dụng cho các kiểu, cấu trúc và lớp, mà Apple khuyến khích như một sự thay đổi thực sự trong mô hình lập trình mà họ gọi là “lập trình hướng giao thức” (tương tự như đặc điểm).

    Swift được giới thiệu tại Worldwide Developers Conference (WWDC) 2014 của Apple.Nó đã trải qua quá trình nâng cấp lên phiên bản 1.2 trong năm 2014 và nâng cấp lớn hơn cho Swift 2 tại WWDC 2015. Ban đầu, ngôn ngữ độc quyền, phiên bản 2.2 được được chuyển sang phần mềm nguồn mở theo Giấy phép Apache 2.0 vào ngày 3 tháng 12 năm 2015, dành cho các nền tảng của Apple và Linux.[17][18]

    Thông qua phiên bản 3.0, cú pháp của Swift đã trải qua quá trình phát triển quan trọng, với nhóm nòng cốt làm cho sự ổn định nguồn trở thành trọng tâm trong các phiên bản sau.[19][20] Trong quý đầu tiên của năm 2018, Swift đã vượt qua Objective-C về mức độ phổ biến.[21]

    Swift 4.0, được phát hành vào năm 2017, đã giới thiệu một số thay đổi đối với một số lớp và cấu trúc tích hợp. Mã được viết bằng các phiên bản trước của Swift có thể được cập nhật bằng chức năng di chuyển được tích hợp trong Xcode

    Vào tháng 3 năm 2017, chưa đầy 3 năm sau khi chính thức ra mắt, Swift đã đứng đầu trong bảng xếp hạng TIOBE hàng tháng về các ngôn ngữ lập trình phổ biến nhất.[22] Một tài liệu 500 trang về Swift cũng được phát hành tại WWDC, miễn phí trên iBooks Store.

    Nguồn: WIKI, bạn có thể đọc thêm về lịch sử hình thành và phát triển và các thông tin khác ở WIKI

    Giới thiệu về Swift

    Apple định nghĩa về ngôn ngữ lập trình Swift là một cách tuyệt vời để viết phần mềm, cho dù đó là phần mềm trên điện thoại, máy tính, server hay bất kể thứ gì khác chạy code. Nó là ngôn ngữ lập trình an toàn, nhanh chóng và có tính tương tác, kết hợp với tư duy ngôn ngữ hiện đại tốt nhất với sự không ngoan từ văn hoá kỹ thuật rộng lớn của Apple và những đóng góp đa dạng từ cộng đồng mã nguồn mở. Trình biên dịch thì tối ưu hoá cho hiệu suất và ngôn ngữ được tối ưu hoá cho sự phát triển mà không ảnh hưởng tới cả hai.

    Swift rất thân thiện với các lập trình viên mới, nó là ngôn ngữ lập trình chất lượng công nghiệp, biểu cảm và thú vị như ngôn ngữ kịch bản. Viết code Swift trong Playground cho phép bạn thử nghiệm code và xem kết quả ngay lập tức mà không tốn chi phí để xây dụng ứng dụng.

    Swift xác định loại bỏ các lớp lớn các lỗi lập trình phổ biến bằng cách áp dụng các mẫu lập trình hiện đại:

    • Các biến luôn được khởi tạo trước khi sử dụng.
    • Các chỉ số mảng được kiểm tra lỗi ngoài giới hạn(out-of-bounds).
    • Số nguyên được kiểm tra tràn(overflow).
    • Optionals đảm bảo rằng các giá trị nil được xử lý rõ ràng.
    • Bộ nhớ được quản lý tự động.
    • Xử lý lỗi cho phép phục hồi có kiểm soát từ các lỗi không mong muốn.

    Mã Swift được biên dịch và tối ưu hóa để tận dụng tối đa phần cứng hiện đại. Cú pháp và thư viện chuẩn đã được thiết kế dựa trên nguyên tắc hướng dẫn rằng cách rõ ràng để viết mã của bạn cũng sẽ hoạt động tốt nhất. Sự kết hợp giữa an toàn và tốc độ khiến Swift trở thành lựa chọn tuyệt vời cho mọi thứ từ “Xin chào, thế giới!” cho toàn bộ hệ điều hành.

    Swift kết hợp suy luận kiểu mạnh mẽ và so khớp mẫu với cú pháp nhẹ, hiện đại, cho phép các ý tưởng phức tạp được thể hiện một cách rõ ràng và ngắn gọn. Kết quả là mã không chỉ dễ viết hơn mà còn dễ đọc và dễ bảo trì hơn.

    Swift đã được phát triển trong nhiều năm và nó tiếp tục phát triển với các tính năng và khả năng mới. Mục tiêu của Apple dành cho Swift rất tham vọng. Apple rất muốn xem bạn tạo thật nhiều ứng dụng với Swift.

    Khả năng tương thích phiên bản

    Phần này mô tả Swift 5.8, phiên bản mặc định của Swift được bao gồm trong Xcode 14. Bạn có thể sử dụng Xcode 14 để xây dựng các target được viết bằng Swift 5.8, Swift 4.2 hoặc Swift 4.

    Khi bạn sử dụng Xcode 14 để xây dựng code Swift 4 và Swift 4.2, hầu hết các chức năng của Swift 5.8 đều khả dụng. Điều đó nói rằng, những thay đổi sau chỉ có sẵn cho code sử dụng Swift 5.8 trở lên:

    Các hàm trả về loại không trong suốt yêu cầu thời gian chạy Swift 5.1.

    Sự cố gắng? biểu thức không đưa ra mức tùy chọn bổ sung cho các biểu thức đã trả về các tùy chọn.

    Biểu thức khởi tạo số nguyên lớn được suy ra là kiểu số nguyên chính xác. Ví dụ: UInt64(0xffff_ffff_ffff_ffff) đánh giá giá trị chính xác thay vì tràn.

    Đồng thời yêu cầu Swift 5.8 hoặc version cao hơn và một phiên bản của thư viện tiêu chuẩn Swift cung cấp các loại đồng thời tương ứng. Trên các nền tảng của Apple, hãy đặt target triển khai ít nhất là iOS 13, macOS 10.15, tvOS 13 hoặc watchOS 6.

    Targer được viết bằng Swift 5.8 có thể phụ thuộc vào Target được viết bằng Swift 4.2 hoặc Swift 4 và ngược lại. Điều này có nghĩa là nếu bạn có một dự án lớn được chia thành nhiều Framework, bạn có thể di chuyển code của mình từ Swift 4 sang Swift 5.8 bằng một Framework.

    Tổng kết

    Vậy là chúng ta đã đã biết qua hầu hết các thông tin về Swift, bài tiếp theo mình sẽ chia sẻ với các bạn cách làm quen với công cụ XCode và thực hiện những dòng code đầu tiên trên ngôn ngữ lập trình Swift.

  • iOS/Swift/Localization: Cách tách Localizable.strings thành nhiều file theo từng màn hình

    iOS/Swift/Localization: Cách tách Localizable.strings thành nhiều file theo từng màn hình

    Xin chào, trong số chúng ta hẳn không ít developer đã từng gặp tình trạng phải ngồi sửa các conflict file localizable strings khi làm việc, nhất là khi dự án của bạn có nhiều người tham gia lúc này file localizable.strings sẽ như là một đấu trường hỗn loạn với rất nhiều developer xâu xé, thi nhau chiếm đất. Người này sửa lên text người kia, người này sửa bug của mình lại tạo thành các bug của người khác. Khi dự án có thêm nhiều tính năng, file Localizable.strings sẽ càng ngày càng phình to ra, khi nó quá lớn thì một số máy MAC của anh em sẽ gặp tình trạng lag, chậm khi sửa file này.

    Câu hỏi đặt ra là để giải quyết bài toán này thì chúng ta cần phải làm gì?

    Chia để trị là cách mình giải quyết vấn đề này.


    Nếu bạn chưa làm ứng dụng có tính năng đa ngôn ngữ bao giờ thì quay lại đọc bài của mình trước rồi quay lại nhé!
    HƯỚNG DẪN THỰC HIỆN ỨNG DỤNG MOBILE HỖ TRỢ ĐA NGÔN NGỮ

    Ưu điểm của việc tách file Localizable.strings theo từng màn hình

    • Mỗi màn hình có một file .strings quản lý riêng vì vậy không bị conflict khi merge code
    • Không bị bug khi fix string màn hình A lại lỗi màn hình B, C, D …
    • Dễ dàng quản lý, dễ dàng maintain
    • Không bị vì lỗi format trên file Localizable.strings mà cả ứng dụng không chạy được đa ngôn ngữ

    Ý tưởng

    Theo cách kiến trúc lập trình phần mềm, chúng ta luôn tìm cách phân tách các thành phần riêng để xử lí các tác vụ, và giảm tải cho các phần càng ngày càng lớn lên khi ứng dụng có nhiều tính năng hơn. Vậy tại sao chúng ta lại để file Localization của mình đến hàng nghìn dòng đến vạn dòng. Không thể chịu được cảnh mở file localizable giật lag, cuộn mỏi tay. Mình đã suy nghĩ đến giải pháp chia ra để trị, mỗi màn hình sẽ có một file .strings riêng để thực hiện Localization.

    Cách thực hiện

    Từ rất lâu Apple đã cung cấp cho chúng ta cách thức để làm được việc này.

    Nếu bạn muốn thực hành luôn thì có thể tài project bắt đầu ở đây nhé.

    Bước 1: Tạo 2 màn hình tương ứng với 2 tính năng của ứng dụng.

    Bước 2: Tạo 2 file .strings tương ứng với 2 màn hình là FirstScreen.strings và SecondScreen.strings như hình dưới, bạn copy nội dung từ File Localizable.strings rồi xoá file đi như hình.

    Bước 3: Sửa lại extension String file LanguageManager.swift như sau

    extension String {
        var localized: String {
            // default language of device
            let currentLanguage = LanguageManager.shared.getAppLanguage().rawValue
            guard let bundlePath = Bundle.main.path(forResource: currentLanguage, ofType: "lproj"),
                  let bundle = Bundle(path: bundlePath),
                  let tableName = self.split(separator: ".").first// lấy table name từ string
            else {
                return self
            }
            
            return NSLocalizedString(self, tableName: String(tableName), bundle: bundle, value: "", comment: "")
        }
    }

    Để sử dụng chúng ta code như sau:

    lbHeaderTitle.text = Localization.SecondScreen.title.localized

    NOTE: Để hàm này hoạt động đúng thì tất cả thành viên trong dự án sẽ phải tuân thủ cách đặt tên nằm trong .strings.
    Ví Dụ: Feature1.strings, thì nội dung bên trong phải là Feature1.xxxx, xxxx ở đây là key string của bạn.

    Nếu bạn cảm thấy việc tuân thủ luật này quá cứng nhắc dễ mắc sai lầm thì chúng ta có một hàm mới như sau:

    // Định nghĩa các table Name, lưu ý đặt tên phải trùng với tên file .strings
    enum LocalizationTableName: String {
        case firstScreen = "FirstScreen"//FirstScreen.strings
        case secondScreen = "SecondScreen"//SecondScreen.strings
    }
    
    extension String {
        func localized(tableName: LocalizationTableName) -> String {
            // default language of device
            let currentLanguage = LanguageManager.shared.getAppLanguage().rawValue
            guard let bundlePath = Bundle.main.path(forResource: currentLanguage, ofType: "lproj"),
                  let bundle = Bundle(path: bundlePath)
            else {
                return self
            }
            
            return NSLocalizedString(self, tableName: tableName.rawValue, bundle: bundle, value: "", comment: "")
        }
    }

    Khi sử dụng chúng ta sẽ code như sau:

    lbHeaderTitle.text = Localization.SecondScreen.title.localized(tableName: .secondScreen)

    Vậy là chúng ta đã thành công tạo localization cho từng màn hình. Mình hi vọng bài viết sẽ giúp ích cho các bạn ở các dự án sau này.

  • Schedulers in Swift Combine Framework

    Schedulers in Swift Combine Framework

    iOS 13 Apple đã giới thiệu đến các developer một big framework, có tên là Combine. Framework này cung cấp rất nhiều thứ thú vị, trong đó có Schedulers. Trong bài này mình sẽ giới thiệu đến các bạn Schedulers.

    Nội dung:

    • Scheduler trong Combine là gì?
    • Các kiểu của Scheduler trong combine.
    • Làm thế nào để switch schedulers?
    • Làm thế nào để perform asynchronous với Combine?
    • Sự khác nhau giữa receive(on:) và subscribe(on:)?

    Scheduler là gì?

    Scheduler Là cơ chế đồng bộ hoá của framework Combine. Nó xác định Context là gì, thực hiện công việc ở đâu (where), thực hiện công việc khi nào (when)

    • Where: Có nghĩa là Run loop hiện tại, dispatch queue hoặc operation queue.
    • When: Có nghĩa là thời gian ảo, tính theo thời gian của Scheduler, công việc được thực hiện bởi Scheduler phải tuân thủ theo thời gian của Scheduler, có thể không tương ứng với thời gian thực tế của hệ thống.

    Các kiểu của Scheduler trong combine

    Framework Combine cung cấp nhiều kiểu của Schedule khác nhau:

    • DispatchQueue: Thực hiện công việc trên một dispatch queue cụ thể: serial, concurrent, main and global. Thông thường serial, global dùng cho các công việc dưới background, và main queue sử dụng cho việc update UI. từ Xcode 11 GM Seed thì concurrent queues không được khuyến khích sử dụng.
    • OperationQueue: Tương tự như DispatchQueue, sử dụng OperationQueue.main cho các công việc liên quan đến UI, và các queue khác sử dụng cho công việc background. Theo như bài này trên một diễn đàn về Swift thì không khuyến khích sử dụng operationQueue với maxConcurrentOperations lớn hơn 1.
    • RunLoop: Thực hiện công việc trên một RunLoop cụ thể
    • ImmediateScheduler: Thực hiện các hành động đồng bộ ngay lập tức. App sẽ terminate với một fatalError nếu bạn cố gắng thực hiện delay task.

    Sử dụng RunLoop.main, DispatchQueue.main hoặc OperationQueue.main để thực hiện công việc liên quan UI. Không có sự khác biệt giữa chúng.

    Scheduler Mặc định

    Ngay cả khi bạn không chỉ định bất kì scheduler nào, Combine vẫn cung cấp cho bạn một scheduler mặc định, scheduler này sử dụng cùng thead với nơi nó được tạo ra. Ví dụ: nếu bạn bắn một sự kiện nào đó từ background thread thì bạn sẽ nhận được sự kiện đó cùng thread với nơi scheduler được tạo ra là background thread

    1. In ra true nếu nhận được sự kiện ở main thread và false nếu ở thead khác
    2. gửi một sự kiện đi từ main thread
    3. gửi một sự kiện từ global

    Và kết quả in ra ở đây lần lượt là true false. Đồng nghĩa là scheduler ở nơi nhận sự kiện giống với scheduler ở nơi sự kiện được sinh ra.

    Switching Schedulers

    Thông thường các hoạt như call API được xử lý ở background thread để UI không bị block. Sau khi call API thì thường update lại UI và công việc này được thực hiện trên main thread. Cách thức của Combine để thực hiện việc này là schitch schedulers. Nó được thực hiện với sự trợ giúp của hai methods: subscribe(on:) và receive(on:).

    • receive(on:) Method này thay scheduler của tất cả những operators sau nó

      Và đây là kết quả:

      Chúng ta có thể thấy map và sink đứng sau receive và đã thay đổi scheduler bởi receive.

      * Vị trí của receive là rất quan trọng bởi vì nó chỉ làm thay đổi scheduler của những operators đứng sau nó
    • subscribe(on:) Method này thay đổi scheduler của subscribe, cancel, and request operations.

      và đây là kết quả

      Nhìn khá giống với receive nhỉ? Cùng nhau đi đến một ví dụ nữa nhé. Cụ thể bây giờ chúng ta chuyển subscribe xuống một dong

      Lúc này kết quả in ra vẫn là false false

      * Vị trí của subscribe là không quan trọng, chỉ đơn giản là đăng ký event đó ở thread nào thôi.

    Thực hiện Asynchronous với Combine

    Trong thực tế khi lập trình chúng ta thường kết hơp hai methods subscribe(on:) and receive(on:). Cùng đi vào ví dụ cụ thể nhé!

    Ví dụ mình có một công việc cần thời gian dài để hoàn thành

    Khi call với main thread nó sẽ làm UI bị block trong 10s. Hãy nhớ rằng schuduler mặc định sẽ chạy cùng thread ở nơi nó được sinh ra

    Với lần call này thì ‘hello’ sẽ được in ra sau, cụ thể là đợi in ra ‘Received value’ sau đó mới thực hiện in ‘hello’. Block UI như thế này thực sự không cho phép trong lập trình. Cùng đi tiếp để xem hướng giải quyết vấn đề này trong trong Combile nhé!

    Pattern phổ biến để thực hiện asynchronous trong Combile là thực hiện subscribe ở background thread và recive ở main thread

    với lần call này thì ‘hello’ đã được in ra trước. Tuyệt vời! bài toán block UI đã được giải quyết.

    Bài viết này mình đã giới thiếu đến các bạn Scheduler, một phần trong Combile được Apple giới thiệu ở iOS 13

    Mình hi vọng bài viết có thể giúp ích được cho các bạn. Chúc các bạn thành công!

  • Kiểm tra kết nối của thiết bị với NWPathMonitor

    Kiểm tra kết nối của thiết bị với NWPathMonitor

    Hi các bạn, Trong quá trình develop của mình chắc hẳn các bạn đã sử dụng đến chức năng kiểm tra tình trạng kết nối của device. Đơn giản như việc kiểm tra internet khi call API hay download một cái gì đó.

    Trước đây thông thường chúng ta sử dụng Network Reachability.

    Network Reachability ở đây hiểu nôm na là trạng thái kết nối mạng của device. Chúng ta có thể sử dụng để xác định rằng device đang online hay offline, thậm chí là đang dùng mạng wifi hay gói dữ liệu data từ nhà mạng. Tuy nhiên đây không phải một thư viện chính thống từ Apple và chúng ta phải nhúng vào source code của mình trước khi sử dụng.

    Với iOS 12, Apple giới thiệu tới các developer một framework mới để kiểm tra tình trạng kết nối của device. NWPathMonitor có lẽ đây là một sự thay thế hoàn hảo cho Reachability.

    Trong sự kiện WWDC tháng 6 năm 2018, một framework mới có tên Network framework được giới thiệu dành cho iOS 12 trở đi. Một đối tượng có tên NWPathMonitor, framework này đem đến cho chúng ta một cách chính thống để xác định trạng thái kết nối mạng thay vì phải dùng thư viện của bên thư ba như từ trước tới nay.

    Dưới đây là cách sử dụng của NWPathMonitor

    Để sử dụng NWPathMonitor chúng ta chỉ cần import Network và sau đó tạo một đối tượng của NWPathMonitor như sau. Tất nhiên ứng dụng của các bạn phải hỗ trợ từ iOS 12 trở lên nhé.

    Với khởi tạo như ở trên thì chúng ta đang kiểm tra connect của tất cả tất cả các Interface type. Nếu chúng ta chỉ quan tâm đến Nếu chỉ quan tâm đến những thay đổi của một network adapter cụ thể ví dụ như Wifi chẳng hạn, chúng ta có thể khởi tạo bằng phương thức init(requiredInterfaceType:) với tham số truyền vào là một kiểu dữ liệu NWInterface.InterfaceType như sau:

    Đây là các interface type mà NWPathMonitor hỗ trợ theo dõi:

    • cellular
    • loopback
    • wifi
    • wiredEthernet
    • other

    Để nhận được các thay đổi về network state, chúng ta chỉ cần gán callback cho một thuộc tính có tên pathUpdateHandler, callback này sẽ được gọi đến bất cứ khi nào có sự thay đổi đối với network, ví dụ như khi điện thoại chuyển từ sử dụng cellular sang wifi. Trong callback có một giá trị trả về có kiểu NWPath giúp chúng ta có thể dễ dàng xác định được trạng thái đã connect hay chưa:

    Đối tượng NWPath có các thuộc tính mà qua đó chúng ta thể dùng để xác định khá nhiều trạng thái của network. Một trong số đó là một thuộc tính khá thú vị mang tên isExpensive dùng để xác định xem network hiện tại có được coi là đắt tiền hay không. Chúng ta cũng có thể kiểm tra xem có hỗ trợ DNS, IPv4 hoặc IPv6 hay không. Ngoài ra nếu muốn xem network trước khi bị thay đổi sang network hiện tại là gì chúng ta có thể sử dụng thuộc tính usedInterfaceType.

    Để bắt đầu lắng nghe trạng thái connect của device, chúng ta gọi phương thức start(). Và gọi stop() khi muốn kết thúc việc lắng nghe.

    Khi đã hoàn tất quá trình lắng nghe sự thay đổi, chúng ta chỉ cần đơn giản gọi phương thức cancel(). Lưu ý rằng một khi đã gọi phương thức cancel() chúng ta không thể gọi phương thức start() được nữa. Thay vào đó chúng ta cần khởi tạo một biến NWPathMonitor mới.

    Trên đây mình đã giới thiệu đến các bạn về NWPathMonitor một framework hỗ trợ từ iOS 12 của Apple, cũng như cách sử dụng của nó.

    Mình hi vọng bài viết có thể giúp ích được cho các bạn. Chúc các bạn thành công!

  • [Mobile – Flutter] Slider trong Flutter

    [Mobile – Flutter] Slider trong Flutter

    Bản thân Flutter là một framework khá hoàn thiện đến mức việc tạo giao diện với Flutter trở nên dễ dàng và nhanh chóng đến mức bất ngờ! Điều này khiến lập trình viên Flutter cảm thấy việc lập trình mobile quá dễ (trừ khi họ là người đã từng nếm trái đắng từ khi code native). Mình cũng thấy dễ cho đến khi nhìn thấy giao diện ứa nước mắt mà designer gửi (đôi khi là kèm theo video animation siêu xịn…).

    Thế bạn đã trang bị cho mình gì để ứng phó trước những design đẹp (hoặc k) nào? Bạn đã biết custom Slider trong Flutter chưa? Hay chỉ là những bé slider cũ mèn như này này này:

    mặc định
    range slider
    cupertino *hình ảnh chụp từ video youtube nên hơi mờ

    Nhưng khi nhận được design thì trông như này

    như này nè
    rồi như này
    trông cũng dễ?

    Chắc ai nhận design cũng sẽ nghĩ: trông dễ phết, khó thì dùng thư viện (nhiều thư viện Slider quá mà). Có thể kể ra một số thư viện cho ai muốn dùng nè:

    Đọc đến đây thì ai thích dùng thư viện có thể dừng lại và tham khảo 3 thư viện này. Đôi khi một số lại thấy thư viện thì quá là nặng thêm nhiều thứ lắt nhắt, rồi cho customize đủ thứ mà cái mình cần thì không có? Hoặc bạn muốn tự mình thử sức làm những slider này, thì tiếp tục đọc cách custom slider nào :3

    Đầu tiên, chúng ta sẽ tìm hiểu về các thành phần của 1 slider và tên gọi của nó trong Flutter với thiết kế theo Material: (đoạn này cop trên document lười dịch nên nhờ bạn đọc tạm nhé)

    • The “thumb”, which is a shape that slides horizontally when the user drags it.
    • The “track”, which is the line that the slider thumb slides along.
    • The “value indicator”, which is a shape that pops up when the user is dragging the thumb to indicate the value being selected.
    • The “active” side of the slider is the side between the thumb and the minimum value.
    • The “inactive” side of the slider is the side between the thumb and the maximum value.
    • The “tick marks”, which are regularly spaced marks that are drawn when using discrete divisions.
    • The “value indicator”, which appears when the user is dragging the thumb to indicate the value being selected.
    • The “overlay”, which appears around the thumb, and is shown when the thumb is pressed, focused, or hovered. It is painted underneath the thumb, so it must extend beyond the bounds of the thumb itself to actually be visible.

    Để thiết kế ra một Slider thì đầu tiên chúng ta sẽ xem đến các widget trực tiếp để tạo ra slider: Slider, RangeSlider, and CupertinoSlider(Flutter Widget of the Week) – YouTube. Tuy nhiên khi xem document (ờ lại là document, document của Flutter là tài liệu hữu ích nhất mà dev Flutter có thể tìm được bên cạnh source code open của nó) thì bạn phát hiện ra nó không có nhiều thứ liên quan đến giao diện cho lắm, ngoài việc cho phép đổi tí màu. Bạn bắt đầu tuyệt vọng.

    một constructor của slider
    có gì đó

    và rồi cuối document, họ bảo, vẻ bề ngoài của nó thì dùng SliderThemeData từ widget SliderTheme hoặc một cái theme nào đó cha ông của nó mà có định nghĩa sliderTheme. Yeah XD

    Bạn tiếp tục đi đến wiki của SliderThemeData xem tùy chỉnh được gì, ở đây nhé SliderThemeData class – material library – Dart API (flutter.dev). Có thể chỉnh được kha khá, hình tròn hình chữ nhật, bo góc (hoặc k), … bạn có thể dừng lại, đọc, và thử xem bạn có thể custom như nào (ảnh phía trên là 1 slider đã được 1 tác giả khác tạo ra nhờ tùy chỉnh các thuộc tính này).

    Để rồi nhận ra rằng: nó không giống mấy cái design nhận được xíu nào 🙁

    hình ảnh slider trong blog cuối bài

    Thế làm sao để custom chúng nó như này? Hãy xem Flutter Team đã tạo ra Slider như nào đã nhé :3

    ồ là một file hơn 3000 dòng code và rất nhiều tác giả

    Họ đã tạo ra RoundedRectSliderTrackShape, RoundSliderThumbShape, PaddleSliderValueIndicatorShape, PaddleSliderValueIndicatorShape, RoundSliderTickMarkShape,… và document cũng nói là:

    The thumb, track, tick marks, value indicator, and overlay can be customized by creating subclasses of SliderTrackShapeSliderComponentShape, and/or SliderTickMarkShape. See RoundSliderThumbShapeRectangularSliderTrackShapeRoundSliderTickMarkShapeRectangularSliderValueIndicatorShape, and RoundSliderOverlayShape for examples.

    Document

    Nên cứ nghe theo thôi ha, họ bảo code mẫu rồi đấy, muốn custom thì xem mà học :3 họ bảo kế thừa mấy cái có sẵn này đi mà custom, nhưng custom sao thì họ không nói 🙁 nên t ở đây để giúp bạn đây hehe. Không dài dòng nữa, bắt đầu thôi :3

    Khi bạn extend các lớp kể ở trên, bạn sẽ được trình gợi ý yêu cầu override hàm paint, đây là hàm để vẽ nên chúng nó. Trong hàm paint có một số thứ bạn cần chú ý:
    – Đầu tiên là PaintingContext context, ở đây bạn sẽ lấy được canvas ra và vẽ những gì bạn cần (context.canvas)
    – Tiếp theo là center với Thumb và parentBox với Track, bạn sẽ tìm được những điểm hữu dụng để vẽ
    – Tiếp theo là sliderTheme, từ đó bạn có thể lấy ra các thuộc tính khác mà bạn có thể truyền vào theme của slider (màu active, inactive…)
    – Cuối cùng, mn luôn luôn có thể tham khảo code open của Flutter xem họ làm như nào với các shape mặc định, hoặc tham khảo code của mình sau đây :3

    Đây là code để tạo ra chiếc slider này

    Chụp từ màn hình điện thoại
    import 'package:flutter/material.dart';
    
    class CustomSlider extends StatelessWidget {
      final double max, min, value;
      final void Function(double) onValueChange;
    
      const CustomSlider({
        required this.min,
        required this.max,
        required this.value,
        required this.onValueChange,
        Key? key,
      }) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return Container(
          decoration: BoxDecoration(
            border: Border.all(color: Colors.grey),
            borderRadius: BorderRadius.circular(10),
          ),
          padding: const EdgeInsets.fromLTRB(4, 16, 4, 16),
          child: Row(
            children: [
              Text(min.toInt().toString()),
              const SizedBox(width: 6),
              Expanded(
                child: SliderTheme(
                  data: SliderTheme.of(context).copyWith(
                    trackHeight: 14.0,
                    trackShape: CustomTrackShape(),
                    activeTrackColor: const Color(0xFF219653),
                    inactiveTrackColor: const Color(0xFFF1E1E1),
                    thumbShape: CustomSliderMarkShape(sliderValue: value, tickMarkRadius: 16),
                    thumbColor: const Color(0xFF219653),
                    // overlayColor: Color(0xFF219653).withOpacity(0.5),
                    // overlayShape: RoundSliderOverlayShape(overlayRadius: 24),
                    overlayShape: SliderComponentShape.noOverlay,
                    showValueIndicator: ShowValueIndicator.always,
                    valueIndicatorShape: const RectangularSliderValueIndicatorShape(),
                    valueIndicatorColor: Colors.black,
                    valueIndicatorTextStyle: const TextStyle(
                      color: Colors.white,
                      fontSize: 16.0,
                    ),
                  ),
                  child: Slider(
                    min: min,
                    max: max,
                    value: value,
                    label: '${value.round()}',
                    onChanged: onValueChange,
                  ),
                ),
              ),
              const SizedBox(width: 6),
              Text(max.toInt().toString()),
            ],
          ),
        );
      }
    }
    
    class CustomSliderMarkShape extends SliderComponentShape {
      final double tickMarkRadius;
      final double sliderValue;
    
      CustomSliderMarkShape({
        required this.tickMarkRadius,
        required this.sliderValue,
      });
    
      @override
      Size getPreferredSize(bool isEnabled, bool isDiscrete) {
        return Size(tickMarkRadius, tickMarkRadius);
      }
    
      @override
      void paint(
        PaintingContext context,
        Offset center, {
        required Animation<double> activationAnimation,
        required Animation<double> enableAnimation,
        required bool isDiscrete,
        required TextPainter labelPainter,
        required RenderBox parentBox,
        required SliderThemeData sliderTheme,
        required TextDirection textDirection,
        required double value,
        required double textScaleFactor,
        required Size sizeWithOverflow,
      }) {
        final Canvas canvas = context.canvas;
    
        canvas.drawRRect(
          RRect.fromRectAndRadius(
            Rect.fromCenter(center: center, width: 36, height: 24), const Radius.circular(16)),
          Paint()..color = Colors.white,
        );
    
        canvas.drawRRect(
          RRect.fromRectAndRadius(
            Rect.fromCenter(center: center, width: 36, height: 24), const Radius.circular(16)),
          Paint()..color = Colors.black
            ..style = PaintingStyle.stroke
            ..strokeWidth = 2,
        );
    
        TextSpan span = TextSpan(
          style: TextStyle(
            fontSize: tickMarkRadius * 0.9,
            fontWeight: FontWeight.w700,
            color: Colors.black
          ),
          text: sliderValue.round().toString(),
        );
    
        TextPainter tp = TextPainter(
          text: span,
          textAlign: TextAlign.center,
          textDirection: TextDirection.ltr,
        );
    
        tp.layout();
    
        Offset textCenter = Offset(
          center.dx - (tp.width / 2),
          center.dy - (tp.height / 2),
        );
        tp.paint(canvas, textCenter);
      }
    }
    
    class CustomTrackShape extends RoundedRectSliderTrackShape {
      @override
      Rect getPreferredRect({
        required RenderBox parentBox,
        Offset offset = Offset.zero,
        required SliderThemeData sliderTheme,
        bool isEnabled = false,
        bool isDiscrete = false,
      }) {
        final double trackHeight = sliderTheme.trackHeight ?? 8;
        final double trackLeft = offset.dx;
        final double trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2;
        final double trackWidth = parentBox.size.width;
        return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight);
      }
    }
    // sử dụng
    CustomSlider(
      max: 100,
      min: 10,
      onValueChange: (_) {  },
      value: 30,
    ),


    Mình đã custom cái slider này từ rất lâu rồi (nên code của nó mình không chắc là ổn lắm).

    Và lí do mình viết bài này chủ yếu để nhớ kĩ hơn và muốn chia sẻ với mọi người khi mà chiều nay mình đã custom ra mấy cái extend SliderComponentShape, rồi ghép vào SliderTheme mà reload restart hoài UI không nhận :'( Mình rất bối rối muốn ném máy ra cửa sổ thì nhận ra nếu là RangeSlider thì cần extend RangeSliderTrackShape, RangeSliderThumbShape… (thêm Range cơ). Hóa ra do mình không đọc kĩ (tiện muốn kể chuyện InteractiveViewer ghê mà ai muốn nghe ib nhé :v)

    range slider sau khi custom
    import 'dart:math';
    
    import 'package:flutter/material.dart';
    import 'dart:ui' as ui;
    
    class CustomRangeSliderTrack extends RangeSliderTrackShape {
      const CustomRangeSliderTrack();
    
      @override
      Rect getPreferredRect({
        required RenderBox parentBox,
        Offset offset = Offset.zero,
        required SliderThemeData sliderTheme,
        bool isEnabled = false,
        bool isDiscrete = false,
      }) {
        final double overlayWidth = sliderTheme.overlayShape!.getPreferredSize(isEnabled, isDiscrete).width;
        final double trackHeight = sliderTheme.trackHeight!;
    
        final double trackLeft = offset.dx + overlayWidth / 2;
        final double trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2;
        final double trackRight = trackLeft + parentBox.size.width - overlayWidth;
        final double trackBottom = trackTop + trackHeight;
        // If the parentBox'size less than slider's size the trackRight will be less than trackLeft, so switch them.
        return Rect.fromLTRB(min(trackLeft, trackRight), trackTop, max(trackLeft, trackRight), trackBottom);
      }
    
      @override
      void paint(
        PaintingContext context,
        Offset offset, {
        required RenderBox parentBox,
        required SliderThemeData sliderTheme,
        required Animation<double> enableAnimation,
        required Offset startThumbCenter,
        required Offset endThumbCenter,
        bool isEnabled = false,
        bool isDiscrete = false,
        required TextDirection textDirection,
        double additionalActiveTrackHeight = 2,
      }) {
        final ColorTween inactiveTrackColorTween = ColorTween(
          begin: sliderTheme.disabledInactiveTrackColor,
          end: sliderTheme.inactiveTrackColor,
        );
    
        final Rect trackRect = getPreferredRect(
          parentBox: parentBox,
          offset: offset,
          sliderTheme: sliderTheme,
          isEnabled: isEnabled,
          isDiscrete: isDiscrete,
        );
    
        final Paint activePaint = Paint()
          ..shader = ui.Gradient.linear(
            Offset(0, trackRect.top),
            Offset(0, trackRect.bottom),
            [
              Color(0xFF4BC65E),
              Color(0xFF379A46),
            ],
          );
        final Paint inactivePaint = Paint()
          ..color = inactiveTrackColorTween.evaluate(enableAnimation)!;
        final Size thumbSize = sliderTheme.rangeThumbShape!.getPreferredSize(isEnabled, isDiscrete);
        final double thumbRadius = thumbSize.width / 2;
        assert(thumbRadius > 0);
    
        final Radius trackRadius = Radius.circular(trackRect.height / 2);
    
        context.canvas.drawRRect(
          RRect.fromLTRBAndCorners(
            trackRect.left,
            trackRect.top,
            startThumbCenter.dx,
            trackRect.bottom,
            topLeft: trackRadius,
            bottomLeft: trackRadius,
          ),
          inactivePaint,
        );
        context.canvas.drawRect(
          Rect.fromLTRB(
            startThumbCenter.dx,
            trackRect.top - (additionalActiveTrackHeight / 2),
            endThumbCenter.dx,
            trackRect.bottom + (additionalActiveTrackHeight / 2),
          ),
          activePaint,
        );
        context.canvas.drawRRect(
          RRect.fromLTRBAndCorners(
            endThumbCenter.dx,
            trackRect.top,
            trackRect.right,
            trackRect.bottom,
            topRight: trackRadius,
            bottomRight: trackRadius,
          ),
          inactivePaint,
        );
      }
    }
    
    class CustomRangeSliderThumb extends RangeSliderThumbShape {
      const CustomRangeSliderThumb();
      
      @override
      Size getPreferredSize(bool isEnabled, bool isDiscrete) {
        return Size.fromRadius(20);
      }
      
      @override
      void paint(PaintingContext context,
        Offset center, {
        required Animation<double> activationAnimation, 
        required Animation<double> enableAnimation, 
        bool isDiscrete = false,
        bool isEnabled = false,
        bool? isOnTop,
        required SliderThemeData sliderTheme,
        TextDirection? textDirection,
        Thumb? thumb,
        bool? isPressed,
      }) {
        final Canvas canvas = context.canvas;
        final outerPaint = Paint()
          ..style = PaintingStyle.fill
          ..shader = ui.Gradient.linear(
            Offset(0, 0),
            Offset(0, 30),
            [
              Color(0xFF4BC65E),
              Color(0xFF379A46),
            ],
          );
        canvas.drawCircle(center, 10, outerPaint);
        canvas.drawCircle(center, 5, Paint()..color = Colors.white);
      }
    }
    // dùng như này để có 1 range slider như ý
    // có thể custom thêm gì đó tùy ý nha
    SliderTheme(
      data: SliderThemeData(
        trackHeight: 4,
        rangeTrackShape: CustomRangeSliderTrack(),
        rangeThumbShape: CustomRangeSliderThumb(),
      ),
      child: RangeSlider(
        values: priceRange,
        min: minPrice,
        max: maxPrice,
        onChanged: (value) {
          setState(() {
            priceRange = value;
          });
        },
      ),
    ),

    Tham khảo: Flutter Slider widgets: A deep dive with examples – LogRocket Blog

  • Viết Unit Test cho API sử dụng URLProtocol

    Viết Unit Test cho API sử dụng URLProtocol

    Hi mọi người, Chắc hẳn các bạn đã từng làm những dự án yêu cầu viết Unit Test, UI Test. Về cơ bản thì UT là một cách verify lại logic mình viết ra. Tuy nhiên đối với việc viết UT cho API thì việc verify logic lại gặp khó khăn vì kết quả của API phụ thuộc vào Server.

    Bài này mình sẽ giới thiệu về Mock Network cho việc viết UT.

    Bắt đầu thôi!!!

    Đầu tiên chúng ta tìm hiểu URLProtocol là gì

    Định nghĩa

    URLProtocol là một abstract class xử lý việc tải dữ liệu URL dành riêng cho protocol-specific.
    Mỗi khi URL Loading System nhận được yêu cầu load 1 URL, nó sẽ tìm kiếm trình xử lý giao thức đã đăng ký để xử lý yêu cầu. Mỗi trình xử lý cho hệ thống biết liệu nó có thể xử lý một yêu cầu đã cho thông qua phương thức canInit(with request: URLRequest) của nó hay không?
    Tham số cho phương thức này là yêu cầu giao thức được hỏi nếu nó có thể xử lý. Nếu phương thức trả về true, thì hệ thống tải sẽ dựa vào lớp con URLProtocol này để xử lý yêu cầu và bỏ qua tất cả các trình xử lý khác.

    Custom URLProtocol

    Ở đây mình tạo ra một class URLProtocolMock và kế thừa URLProtocol và override các method như sau:

    • mothod canInit() được gọi để kiểm tra xem Giao thức có thể xử lý loại yêu cầu nhất định hay không.
    • nếu canInit là true, sau đó canonicalRequest() được gọi và chúng ta trả ra đúng request hiện tại
    • Sau đó đến phần tiếp nạp. startLoading() sẽ làm tất cả những gì có thể để tìm nạp nội dung, stopLoading() có thể được gọi để hủy hoặc đánh dấu hoàn thành.

    Bây giờ đến việc viết UT cho một function call API, để viết được UT cho function call API thì chúng ta cần có function call API. Mình sử dụng URLSession để thực hiện call một API đơn giản
    API: https://postman-echo.com/get?test=123
    Method: GET

    Unit Test cho Network

    Tiếp theo mình tạo ra class NetworkServiceTests để viết test cho NetworkServices

    • setUpWithError() mothod này được chạy trước khi thực hiện một test case. Ở đây mình mình setup cho URLSession set configuration.protocolClasses = [URLProtocolMock.self] để thực hiện proccess lấy data từ custom URL Protocol đã tạo ở trên
    • tearDownWithError() mothod này được chạy mỗi khi hoàn thành một test case. Vì mình không cần release gì ở đây nên mình không code gì thêm cho nó.

    Sau khi config cho class test, Mình viết một test case cho trường hợp requestPostmanEcho success

    Lưu ý: khi đặt tên test case thì bắt buộc phải có prefix là “test” nhé, ở đây mình đặt tên test case này là test_requestPostmanEcho_success()

    • line 30-33: tạo ra response mong muốn
    • line 35-38: config response và jsonData cho request

    Vì đã config statusCode 200 nên request requestPostmanEcho trả ra kết quả thành công với response là một dictionary. Mình lấy kết quả trả ra và so sánh với giá trị mong muốn. Tất nhiên là thành công rồi!!!

    Mình sẽ viết thêm một test case nữa, Lần này mình vẫn config response thành công (statusCode: 200) tuy nhiên ở jsonString mình sẽ config thành một dạng không phải là json. Lúc này khi gọi đến requestPostmanEcho thì sẽ nhận được kết quả là error responseFailed.

    Mình so sánh kết quả nhận được từ request với kết quả mong muốn là responseFailed. Tất nhiên là nó cũng sẽ thành công rồi

    Trong ví dụ call API của mình thì có một xử lý riêng cho statusCode 404 NotFound. Các bạn cũng có thể viết một test case cho lỗi 404 này và tất nhiên là phải config statusCode về 404 cho test case đó.

    Trên đây mình đã giới thiệu đến các bạn cách viết UT cho API sử dụng URLProtocol. Từ đây các bạn có thể tuỳ biến cho dự án của mình.

    Mình hi vọng bài viết có thể giúp ích được cho các bạn. Chúc các bạn thành công!

  • Tạo server mock bằng Postman

    Tạo server mock bằng Postman

    Hi mọi người. Trong quá trình develop chắc hẳn các bạn đã từng gặp trường trường hợp server đã define json response nhưng server deploy. Việc chờ server và làm UI trước thì cũng được, nhưng trong khi làm UI mà có một server để thực hiện request và có thể thay đổi response theo mong muốn của mình thì thật là tuyệt vời.

    Trong bài này mình sẽ hướng dẫn cách tạo ra một server mock thông qua postman.

    Tóm tắt nội dung của bài:

    • Gửi một request
    • Lưu request vào collection
    • lưu response
    • Tạo mock server từ collection
    • request đến server mock

    Bắt đầu nào!!!!

    Step 1: Gửi một request

    Send một request bất kì. Mục đích của việc send một request này là để lấy response và lưu lại

    Ở đây mình request với url https://postman-echo.com/get?test=123


    Đây là response từ https://postman-echo.com/get?test=123

    Step 2: Lưu request vào một collection

    Đầu tiền mình thực hiện tạo một collection mới và đặt tên là “Mock Collection”




    Tiếp theo lưu request vào Mock Collection

    Step 3: Lưu response

    Lưu response của request bên trên vào trong Mock Collection




    Sau khi lưu response, các bạn có thể đổi tên response đó, ở đây mình đổi tên thành “test-get”

    Step 4: Tạo Mock Server từ collection

    Tạo Mock Server cho Mock Collection vừa tạo ở trên.


    Đặt tên cho server mock và ấn tạo


    Về cơ bản thì thực hiện đến đây là server mock đã hoàn thành và có thể thực hiện request rồi. Các bạn nhớ copy url của server mock nhé!!!

    Step 5: Request đến server mock

    Dùng url đã copy ở step 4 và thực hiện một request bằng postman
    url mình đã copy ở step 4 như sau: https://f4205914-b48b-4ff4-9dfe-5cbf131a24d4.mock.pstmn.io


    Opp!!! lỗi rồi…. Lúc này server mock sẽ trả ra lỗi 404 như ảnh bên trên. Lý do lỗi ở đây là bạn chưa thêm path cho mock server url.

    Các bạn thêm đuôi /get cho url và đổi method thành GET. Lúc này server mock sẽ trả ra response như đã lưu ở step 3

    sau khi sửa url thì nó sẽ như thế này: https://f4205914-b48b-4ff4-9dfe-5cbf131a24d4.mock.pstmn.io/get



    Các bạn có thể đổi response của server mock theo mong muốn của mình
    Ở đây mình đổi json đã lưu ở step 3 thành {“title”: “Hello World”}. sau khi đổi json response thì ấn Save ở góc trên bên phải.


    Sau khi thay đổi và lưu response xong, thực hiện lại request đến server mock. Và kết quả như sẽ như response đã sửa ở trên

    Trên đây mình đã hướng dẫn các bạn tạo server mock và có thể chỉnh sửa response theo mong muốn của mình.

    Mình hi vọng bài viết có thể giúp ích được cho các bạn. Chúc các bạn thành công!

    Tham khảo: https://learning.postman.com/docs/designing-and-developing-your-api/mocking-data/mocking-with-examples/

  • Ánh xạ quan hệ 1-1 sử dụng chia sẻ khoá chính trong Hibernate

    Ánh xạ quan hệ 1-1 sử dụng chia sẻ khoá chính trong Hibernate

    Các chương trình máy tính thể hiện các nhu cầu thực tế của con người, chúng ánh xạ các đối tượng trong thế giới thực thành các thực thể. Khi thực hiện quá trình ánh xạ đó, chúng ta thực hiện ánh xạ cả mối quan hệ giữa chúng. Trong bài viết này chúng ta đặt mối quan tâm tới các đối tượng có mối quan hệ 1-1 với nhau. Chúng ta sẽ cùng tìm hiểu cách chúng được thể hiện trong chương trình máy tính như thế nào.

    Các đối tượng trong thế giới thực được phản ánh trong chương trình máy tính như thế nào?

    Trước tiên chúng ta thấy các đối tượng sẽ được ánh xạ tương ứng thành các class trong các ngôn ngữ lập trình. Khi chúng được lưu trữ vào database, chúng sẽ được ánh xạ thành các bản ghi của một bảng. Vậy thì mối quan hệ giữa chúng được định nghĩa như thế nào? Đối với các bảng trong database, các khoá trong bảng sẽ thể hiện mối quan hệ giữa các bảng. Đối với quan hệ 1-1 chúng ta có thể định nghĩa theo 2 cách:

    • Sử dụng khoá ngoại duy nhất (một cột được đánh dấu là khoá ngoại và nó cũng là duy nhất trong bảng đó).
    • Hai bảng cùng chia sẻ khoá chính.

    Các đối tượng được phản ánh thành các bảng trong database

    Chúng ta cùng xem xét các thực thể được phản ánh thành các bảng trong database thông qua một vài ví dụ các bảng được thiết kế trong database như thế nào. Đối với cách sử dụng khoá ngoại duy nhất, các bảng có thể được định nghĩa như sau:

    Trong trường hợp bạn sử dụng cách chia sẻ khoá chính giữa hai bảng, các bảng trong database có thể được định nghĩa như sau:

    Các đối tượng được ánh xạ thành các class như thế nào?

    Đối với cách sử dụng khoá ngoại duy nhất, chúng ta có thể tham khảo cách định nghĩa mối quan hệ của chúng trong Spring Boot qua các bài viết sau:

    Trường hợp bạn sử dụng cách chia sẻ khoá chính giữa hai bảng chúng ta tìm hiểu qua từng bước dưới đây.

    Định nghĩa các bảng trong database

    Với ví dụ ở trên các bạn có thể sử dụng đoạn mã sau để tạo ra các bảng:

    CREATE TABLE IF NOT EXISTS `user` (
      `id` BIGINT NOT NULL AUTO_INCREMENT
      , `username` VARCHAR(255) UNIQUE
      , `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
      , `created_by` BIGINT DEFAULT NULL
      , `updated_at` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP
      , `updated_by` BIGINT DEFAULT NULL
      , `deleted_at` DATETIME DEFAULT NULL
      , `deleted_by` BIGINT DEFAULT NULL
      , PRIMARY KEY (`id`)
    );
    
    CREATE TABLE IF NOT EXISTS `user_info` (
      `user_id` BIGINT NOT NULL
      , `first_name` VARCHAR(255)
      , `last_name` VARCHAR(255)
      , PRIMARY KEY (`user_id`)
      , FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION
    );
    

    Chúng ta có thể tham khảo cách migrate các bảng này bằng cách sử dụng Flyway trong bài viết Hướng dẫn migrate cơ sở dữ liệu sử dụng Flyway trong ứng dụng Spring Boot .

    Định nghĩa các entity để ánh xạ các bảng với các class

    Tiếp theo chúng ta cần định nghĩa các entity thành các class tương ứng. Từ đó, chúng ta có thể thực hiện các thao tác CRUD hoặc các thao tác truy vấn trên các bảng tương ứng.

    /**
     * <code>user_info</code>.
     *
     * @author Hieu Nguyen
     */
    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    @Entity(name = "user_info")
    public class UserInfo {
    
      /** <code>user_id</code>. */
      @Id
      private Long userId;
    
      /** <code>first_name</code>. */
      private String firstName;
    
      /** <code>last_name</code>. */
      private String lastName;
    
      @MapsId
      @ToString.Exclude
      @PrimaryKeyJoinColumn
      @Fetch(FetchMode.JOIN)
      @OneToOne(cascade = CascadeType.PERSIST, optional = false, fetch = FetchType.EAGER)
      private User user;
    }
    
    /**
     * <code>user</code>.
     *
     *
     * @author Hieu Nguyen
     */
    @Data
    @Builder
    @ToString
    @NoArgsConstructor
    @AllArgsConstructor
    @Entity(name = "user")
    @EqualsAndHashCode(onlyExplicitlyIncluded = true)
    public class User {
    
      /** <code>id</code>. */
      @Id
      @EqualsAndHashCode.Include
      @GeneratedValue(strategy = GenerationType.IDENTITY)
      private Long id;
    
      /** <code>username</code>. */
      private String username;
    
      /** <code>created_at</code>. */
      private LocalDateTime createdAt;
    
      /** <code>created_by</code>. */
      private Long createdBy;
    
      /** <code>updated_at</code>. */
      private LocalDateTime updatedAt;
    
      /** <code>updated_by</code>. */
      private Long updatedBy;
    
      /** <code>deleted_at</code>. */
      private LocalDateTime deletedAt;
    
      /** <code>deleted_by</code>. */
      private Long deletedBy;
    
      /** <code>user_info.user_id</code> */
      @ToString.Exclude
      @OneToOne(mappedBy = "user", fetch = FetchType.EAGER)
      private UserInfo userInfo;
    }
    

    Chúng ta sử @Id để đánh dấu thuộc tính được ánh xạ tương ứng với trường khoá chính của bảng. Trong ví dụ này, chúng ta sử dụng trường tự tăng để sinh ra khoá chính cho bảng, do đó chúng ta sử dụng @GeneratedValue(strategy = GenerationType.IDENTITY) để thông báo với Hibernate rằng trường này sẽ được tự sinh trong database.

    Tiếp theo là phần quan trọng nhất, chúng ta sử dụng @MapsId để đánh dấu thuốc tính định nghĩa mối quan hệ 1-1 cùng với @OneToOne để xác định thực thể trong bảng có quan hệ 1-1 tương ứng. @MapsId sẽ thông báo cho Hibernate biết rằng chúng ta đang sử dụng khoá chính làm trường để thực hiện phép JOIN.

    Tiếp đến để thực hiện ánh xạ quan hệ 1-1 hai chiều, chúng ta sử dụng @OneToOne(mappedBy = "user", fetch = FetchType.EAGER) để đánh dấu thuộc tính ánh xạ sang thực thể nguồn đã được định nghĩa ở trên. Thuộc tính mappedBy chính là tên thuộc tính được khai báo với @MapsId ở trên.

    Xác nhận việc định nghĩa quan hệ 1-1

    Tiếp theo chúng ta cùng viết một đoạn chương trình nhỏ để kiểm tra lại các bước đã thực hiện ở trên.

    /**
     * Main.
     *
     * @author Hieu Nguyen
     */
    @Component
    @RequiredArgsConstructor
    public class Main implements CommandLineRunner {
    
      private final UserRepository userRepository;
    
      @Override
      @Transactional
      public void run(String... args) throws Exception {
        var uuid = UUID.randomUUID();
        var userInfo = UserInfo.builder().firstName("Hieu-" + uuid).lastName("Nguyen-" + uuid).build();
        var user = User.builder().username("hieunv-" + UUID.randomUUID()).userInfo(userInfo).build();
        userInfo.setUser(user);
        userRepository.save(user);
      }
    }
    

    Chạy thử đoạn chương trình này chúng ta sẽ nhận được output như sau:

    2023-02-15 18:56:38.262 DEBUG 25263 --- [           main] org.hibernate.SQL                        : 
        insert 
        into
            user
            (created_at, created_by, deleted_at, deleted_by, passport_id, updated_at, updated_by, username) 
        values
            (?, ?, ?, ?, ?, ?, ?, ?)
    Hibernate: 
        insert 
        into
            user
            (created_at, created_by, deleted_at, deleted_by, passport_id, updated_at, updated_by, username) 
        values
            (?, ?, ?, ?, ?, ?, ?, ?)
    2023-02-15 18:56:38.264 TRACE 25263 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [TIMESTAMP] - [null]
    2023-02-15 18:56:38.264 TRACE 25263 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [null]
    2023-02-15 18:56:38.264 TRACE 25263 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [TIMESTAMP] - [null]
    2023-02-15 18:56:38.264 TRACE 25263 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [4] as [BIGINT] - [null]
    2023-02-15 18:56:38.264 TRACE 25263 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [5] as [BIGINT] - [null]
    2023-02-15 18:56:38.264 TRACE 25263 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [6] as [TIMESTAMP] - [null]
    2023-02-15 18:56:38.264 TRACE 25263 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [7] as [BIGINT] - [null]
    2023-02-15 18:56:38.264 TRACE 25263 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [8] as [VARCHAR] - [hieunv-48779f8f-4efe-4d15-b658-92ebd3f7d9a3]
    2023-02-15 18:56:38.278 DEBUG 25263 --- [           main] org.hibernate.SQL                        : 
        insert 
        into
            user_info
            (first_name, last_name, user_id) 
        values
            (?, ?, ?)
    Hibernate: 
        insert 
        into
            user_info
            (first_name, last_name, user_id) 
        values
            (?, ?, ?)
    2023-02-15 18:56:38.278 TRACE 25263 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [Hieu-3c8726d4-0d3f-49f6-ba05-819bbb428863]
    2023-02-15 18:56:38.278 TRACE 25263 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [VARCHAR] - [Nguyen-3c8726d4-0d3f-49f6-ba05-819bbb428863]
    2023-02-15 18:56:38.278 TRACE 25263 --- [           main] o.h.type.descriptor.sql.BasicBinder      : binding parameter [3] as [BIGINT] - [9]
    

    Chúng ta thấy rằng có 2 bản ghi đã được insert vào 2 bảng chúng ta đã định nghĩa ở trên.

    Tổng kết

    Trong bài viết này chúng ta đã cùng đi từ các khái niệm cơ bản liên quan đến quan hệ 1-1 cũng như cách triển khai quan hệ này với MySQL database. Sau đó chúng ta cũng viết một ứng dụng đơn giản bằng Spring Boot để minh hoạ cơ chế hoạt động của quan hệ này.

  • Highlight text of UILabel in Swift

    Highlight text of UILabel in Swift

    Hi mọi người, mấy năm trước mình có làm một dự án mobile về mảng Logistics, trong ứng dụng thường sử dụng khá nhiều các text, một số từ được Highlight text đi kèm với các action tương tự như là một button nhằm mục đích gây sự chú ý với người dùng. Để làm được việc này chúng ta cần xác định được vị trí text cần Highlight để thực hiện thay đổi UI cho đoạn text đó gắn action cho nó. Mình thấy đây là một tính năng khá thú vị và sẽ gặp nhiều trong các dự án sắp tới của các bạn. Vậy nên mình xin chia sẻ cách làm của mình như sau.

    Cách Highlight text

    Về lý thuyết để highlight text trong swift thì chúng ta cần l xác định vị trí(position) và độ dài(lenght) của text cần highlight sau đó thực hiện thay đổi thuộc tính của đoạn text đó bằng NSAttributedString.

    Việc bắt sư kiện khi chúng ta tương tác với Highlight text cũng phải tìm vị trí và độ dài của đoạn text, sau đó chúng ta dựa vào điểm người dùng bấm vào trên Label để xác định xem người dùng có bấm đúng vị trí text được highlight hay không.

    1. Cách thực hiện Highlight text

    Đầu tiên chúng ta sẽ tạo một func trong extension của UILabel như sau:

    extension UILabel {
        func highlightText(_ text: String, highlightColor: UIColor, in mainText: String) {
            // chuyển mainText sang NSMutableAttributedString để xử lý tìm range của highlight text
            let highlightAttributedString = NSMutableAttributedString(string: mainText)
            // xác định vị trí, độ dài của text cần highlight
            let range = (mainText as NSString).range(of: text)
            // thêm thuộc tính cho đoạn text cần highlight
            highlightAttributedString.addAttribute(NSAttributedString.Key.foregroundColor, value: highlightColor, range: range)
            // gán giá trị vào label
            self.attributedText = highlightAttributedString
        }
    }

    Khi sử dụng chúng ta sẽ làm như sau:

    Trường hợp này mình sẽ biến chữ “DaoNM2” trong “Good evening! DaoNM2” thành màu cam như sau

    lbInfo.highlightText("DaoNM2", highlightColor: .red, in: "Good evening! DaoNM2")
    Kết quả

    2. Bắt sự kiện và thực hiện hành động khi bấm vào text được Highlight

    Ở mục 1 mình đã hướng dẫn cách thực hiện đổi màu text của 1 đoạn text trong UILabel, vậy để bắt sự kiện khi chúng ta bấm vào thì làm cách nào?

    Đầu tiên chúng ta sẽ tạo một func trong extension của UITapGestureRecognizer, nhằm mục đích xử lí điểm chạm của người dùng và xác định xem có đúng vị trí của text đã được Highlight hay không như sau:

    extension UITapGestureRecognizer {
        func didTapHighlightedTextInLabel(label: UILabel, inRange targetRange: NSRange) -> Bool {
            guard let attributedText = label.attributedText else {
                return false
            }
    
            let mutableStr = NSMutableAttributedString(attributedString: attributedText)
            mutableStr.addAttributes([NSAttributedString.Key.font: label.font!], range: NSRange(location: 0, length: attributedText.length))
               
            // If the label have text alignment. Delete this code if label have a default (left) aligment. Possible to add the attribute in previous adding.
            let paragraphStyle = NSMutableParagraphStyle()
            paragraphStyle.alignment = label.textAlignment
            mutableStr.addAttributes([NSAttributedString.Key.paragraphStyle: paragraphStyle], range: NSRange(location: 0, length: attributedText.length))
    
            // Create instances of NSLayoutManager, NSTextContainer and NSTextStorage
            let layoutManager = NSLayoutManager()
            let textContainer = NSTextContainer(size: CGSize.zero)
            let textStorage = NSTextStorage(attributedString: mutableStr)
               
            // Configure layoutManager and textStorage
            layoutManager.addTextContainer(textContainer)
            textStorage.addLayoutManager(layoutManager)
               
            // Configure textContainer
            textContainer.lineFragmentPadding = 0.0
            textContainer.lineBreakMode = label.lineBreakMode
            textContainer.maximumNumberOfLines = label.numberOfLines
            let labelSize = label.bounds.size
            textContainer.size = labelSize
               
            // Find the tapped character location and compare it to the specified range
            let locationOfTouchInLabel = self.location(in: label)
            let textBoundingBox = layoutManager.usedRect(for: textContainer)
            let textContainerOffset = CGPoint(x: (labelSize.width - textBoundingBox.size.width) * 0.5 - textBoundingBox.origin.x,
                                              y: (labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y)
            let locationOfTouchInTextContainer = CGPoint(x: locationOfTouchInLabel.x - textContainerOffset.x,
                                                         y: locationOfTouchInLabel.y - textContainerOffset.y)
            let indexOfCharacter = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
            return NSLocationInRange(indexOfCharacter, targetRange)
        }
    }

    Trong hàm này mình sẽ dùng NSLayoutManager để xác định xem điểm chạm của người dùng mà UITapGestureRecognizer bắt được có đúng vị trí highlight text hay không.

    func này sẽ trả về cho chúng ta biết được vị trí người dùng đang chạm vào UILabel có trùng với range truyền vào hay không.

    Để ứng dụng nó vào bài toán của chúng ta thì chúng ta sẽ thực hiện nó như sau:

        private func setUpLabelInfo() {
            lbInfo.highlightText("More...", highlightColor: .red, in: "Two one-offs, a roadster and a coupé, mark the end of production of super sports cars powered by the V12 combustion engine in the lead-up to the hybrid era. More...")
            let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tapMore(_:)))
            lbInfo.isUserInteractionEnabled = true
            lbInfo.addGestureRecognizer(tapGesture)
        }
        
        @objc
        private func tapMore(_ gesture: UITapGestureRecognizer) {
            let mainText = lbInfo.text ?? ""
            let highlightText = "More..."
            let highlightTextRange = ((mainText) as NSString).range(of: highlightText)
            if gesture.didTapHighlightedTextInLabel(label: lbInfo, inRange: highlightTextRange) {
                print("Did tap: \(highlightText), hightlightRange: \(highlightTextRange)")
            }
        }

    Ở viewDidLoad chúng ta chỉ cần gọi nó ra là xong.

       override func viewDidLoad() {
            super.viewDidLoad()
    
            lbTitle.textColor = .orange
            lbTitle.textAlignment = .center
            lbTitle.text = "Lambo"
            setUpLabelInfo()
        }

    Kết quả thu được như sau:

    Vậy là chúng ta đã có thể thêm action vào cho text được highlight.

    Từ ví dụ này chúng ta có thể tuỳ biến cho phù hợp dự án của bạn hoặc phát triển nó lên theo cách mà các bạn mong muốn.

    Mình hi vọng bài viết giúp cho các bạn có thêm phương án lựa chọn khi tham gia những dự án có yêu cầu tương tự. Chúc các bạn thành công!