Gọi mã Kotlin qua Java

Trong lớp học lập trình này, bạn sẽ tìm hiểu cách viết hoặc điều chỉnh mã Kotlin để có thể gọi mã liền mạch hơn từ mã Java.

Kiến thức bạn sẽ học được

  • Cách sử dụng @JvmField, @JvmStatic và các chú thích khác.
  • Hạn chế khi truy cập vào một số tính năng ngôn ngữ Kotlin từ mã Java.

Kiến thức bạn cần có

Lớp học lập trình này được viết cho các lập trình viên và giả định rằng bạn có kiến thức cơ bản về Java và Kotlin.

Lớp học lập trình này mô phỏng quá trình di chuyển một phần của một dự án lớn hơn được viết bằng ngôn ngữ lập trình Java để kết hợp mã Kotlin mới.

Để đơn giản hoá, chúng ta sẽ có một tệp .java duy nhất có tên là UseCase.java, tệp này sẽ đại diện cho cơ sở mã hiện có.

Hãy tưởng tượng rằng chúng ta vừa thay thế một số chức năng ban đầu được viết bằng Java bằng một phiên bản mới được viết bằng Kotlin và chúng ta cần hoàn tất việc tích hợp chức năng đó.

Nhập dự án

Bạn có thể sao chép mã của dự án từ dự án GitHub tại đây: GitHub

Ngoài ra, bạn có thể tải và trích xuất dự án từ một kho lưu trữ zip tại đây:

Tải tệp Zip xuống

Nếu bạn đang sử dụng IntelliJ IDEA, hãy chọn "Import Project" (Nhập dự án).

Nếu bạn đang sử dụng Android Studio, hãy chọn "Import project (Gradle, Eclipse ADT, etc.)" (Nhập dự án (Gradle, Eclipse ADT, v.v.)).

Hãy mở UseCase.java và bắt đầu xử lý các lỗi mà chúng ta thấy.

Hàm đầu tiên gặp vấn đề là registerGuest:

public static User registerGuest(String name) {
   User guest = new User(Repository.getNextGuestId(), StringUtils.nameToLogin(name), name);
   Repository.addUser(guest);
   return guest;
}

Lỗi cho cả Repository.getNextGuestId()Repository.addUser(...) đều giống nhau: "Không thể truy cập vào thành phần không tĩnh từ một ngữ cảnh tĩnh."

Bây giờ, hãy xem một trong các tệp Kotlin. Mở tệp Repository.kt.

Chúng ta thấy rằng Kho lưu trữ của chúng ta là một singleton được khai báo bằng cách sử dụng từ khoá đối tượng. Vấn đề là Kotlin đang tạo một thực thể tĩnh bên trong lớp của chúng ta, thay vì hiển thị các thực thể này dưới dạng các thuộc tính và phương thức tĩnh.

Ví dụ: bạn có thể tham chiếu Repository.getNextGuestId() bằng cách sử dụng Repository.INSTANCE.getNextGuestId(), nhưng có một cách tốt hơn.

Chúng ta có thể yêu cầu Kotlin tạo các phương thức và thuộc tính tĩnh bằng cách chú thích các phương thức và thuộc tính công khai của Kho lưu trữ bằng @JvmStatic:

object Repository {
   val BACKUP_PATH = "/backup/user.repo"

   private val _users = mutableListOf<User>()
   private var _nextGuestId = 1000

   @JvmStatic
   val users: List<User>
       get() = _users

   @JvmStatic
   val nextGuestId
       get() = _nextGuestId++

   init {
       _users.add(User(100, "josh", "Joshua Calvert", listOf("admin", "staff", "sys")))
       _users.add(User(101, "dahybi", "Dahybi Yadev", listOf("staff", "nodes")))
       _users.add(User(102, "sarha", "Sarha Mitcham", listOf("admin", "staff", "sys")))
       _users.add(User(103, "warlow", groups = listOf("staff", "inactive")))
   }

   @JvmStatic
   fun saveAs(path: String?):Boolean {
       val backupPath = path ?: return false

       val outputFile = File(backupPath)
       if (!outputFile.canWrite()) {
           throw FileNotFoundException("Could not write to file: $backupPath")
       }
       // Write data...
       return true
   }

   @JvmStatic
   fun addUser(user: User) {
       // Ensure the user isn't already in the collection.
       val existingUser = users.find { user.id == it.id }
       existingUser?.let { _users.remove(it) }
       // Add the user.
       _users.add(user)
   }
}

Thêm chú thích @JvmStatic vào mã bằng IDE.

Nếu chúng ta chuyển về UseCase.java, các thuộc tính và phương thức trên Repository sẽ không còn gây ra lỗi, ngoại trừ Repository.BACKUP_PATH. Chúng ta sẽ quay lại vấn đề đó sau.

Bây giờ, hãy sửa lỗi tiếp theo trong phương thức registerGuest().

Hãy xem xét trường hợp sau: chúng ta có một lớp StringUtils với một số hàm tĩnh cho các thao tác chuỗi. Khi chuyển đổi sang Kotlin, chúng tôi đã chuyển đổi các phương thức thành hàm mở rộng. Java không có hàm mở rộng, vì vậy Kotlin sẽ biên dịch các phương thức này dưới dạng hàm tĩnh.

Rất tiếc, nếu xem xét phương thức registerGuest() bên trong UseCase.java, chúng ta có thể thấy rằng có gì đó không ổn:

User guest = new User(Repository.getNextGuestId(), StringUtils.nameToLogin(name), name);

Lý do là Kotlin đặt các hàm "cấp cao nhất" hoặc cấp gói này vào bên trong một lớp có tên dựa trên tên tệp. Trong trường hợp này, vì tệp có tên là StringUtils.kt, nên lớp tương ứng có tên là StringUtilsKt.

Chúng tôi có thể thay đổi tất cả các tham chiếu của StringUtils thành StringUtilsKt và khắc phục lỗi này, nhưng đây không phải là cách lý tưởng vì:

  • Có thể có nhiều vị trí trong mã của chúng ta cần được cập nhật.
  • Tên này nghe không hay.

Vì vậy, thay vì tái cấu trúc mã Java, hãy cập nhật mã Kotlin để sử dụng một tên khác cho các phương thức này.

Mở StringUtils.Kt rồi tìm khai báo gói sau:

package com.google.example.javafriendlykotlin

Chúng ta có thể yêu cầu Kotlin sử dụng một tên khác cho các phương thức cấp gói bằng cách dùng chú thích @file:JvmName. Hãy dùng chú thích này để đặt tên cho lớp StringUtils.

@file:JvmName("StringUtils")

package com.google.example.javafriendlykotlin

Bây giờ, nếu xem lại UseCase.java, chúng ta có thể thấy lỗi cho StringUtils.nameToLogin() đã được giải quyết.

Rất tiếc, lỗi này đã được thay thế bằng một lỗi mới về các tham số được truyền vào hàm khởi tạo của User. Hãy tiếp tục đến bước tiếp theo và khắc phục lỗi cuối cùng này trong UseCase.registerGuest().

Kotlin hỗ trợ các giá trị mặc định cho tham số. Chúng ta có thể xem cách các thành phần này được dùng bằng cách xem bên trong khối init của Repository.kt.

Repository.kt:

_users.add(User(102, "sarha", "Sarha Mitcham", listOf("admin", "staff", "sys")))
_users.add(User(103, "warlow", groups = listOf("staff", "inactive")))

Chúng ta có thể thấy rằng đối với người dùng "warlow", chúng ta có thể bỏ qua việc nhập giá trị cho displayName vì có một giá trị mặc định được chỉ định cho giá trị này trong User.kt.

User.kt:

data class User(
   val id: Int,
   val username: String,
   val displayName: String = username.toTitleCase(),
   val groups: List<String> = listOf("guest")
)

Rất tiếc, cách này không hoạt động khi gọi phương thức từ Java.

UseCase.java:

User guest = new User(Repository.getNextGuestId(), StringUtils.nameToLogin(name), name);

Ngôn ngữ lập trình Java không hỗ trợ các giá trị mặc định. Để khắc phục vấn đề này, hãy yêu cầu Kotlin tạo các hàm quá tải cho hàm khởi tạo của chúng ta bằng chú thích @JvmOverloads.

Trước tiên, chúng ta phải cập nhật một chút cho User.kt.

Vì lớp User chỉ có một hàm khởi tạo chính và hàm khởi tạo này không có chú thích nào, nên từ khoá constructor đã bị bỏ qua. Tuy nhiên, vì chúng ta muốn chú thích nó, nên phải thêm từ khoá constructor:

data class User constructor(
    val id: Int,
    val username: String,
    val displayName: String = username.toTitleCase(),
    val groups: List<String> = listOf("guest")
)

Khi có từ khoá constructor, chúng ta có thể thêm chú thích @JvmOverloads:

data class User @JvmOverloads constructor(
    val id: Int,
    val username: String,
    val displayName: String = username.toTitleCase(),
    val groups: List<String> = listOf("guest")
)

Nếu chuyển về UseCase.java, chúng ta có thể thấy rằng không còn lỗi nào trong hàm registerGuest nữa!

Bước tiếp theo là sửa lệnh gọi bị hỏng đến user.hasSystemAccess() trong UseCase.getSystemUsers(). Hãy chuyển sang bước tiếp theo để thực hiện việc đó hoặc tiếp tục đọc để tìm hiểu kỹ hơn về những việc mà @JvmOverloads đã làm để khắc phục lỗi.

@JvmOverloads

Để hiểu rõ hơn về chức năng của @JvmOverloads, hãy tạo một phương thức kiểm thử trong UseCase.java:

private void testJvmOverloads() {
   User syrinx = new User(1001, "syrinx");
   User ione = new User(1002, "ione", "Ione Saldana");

   List<String> groups = new ArrayList<>();
   groups.add("staff");
   User beaulieu = new User(1002, "beaulieu", groups);
}

Chúng ta có thể tạo một User chỉ với hai tham số, idusername:

User syrinx = new User(1001, "syrinx");

Chúng ta cũng có thể tạo một User bằng cách thêm tham số thứ ba cho displayName trong khi vẫn sử dụng giá trị mặc định cho groups:

User ione = new User(1002, "ione", "Ione Saldana");

Nhưng bạn không thể bỏ qua displayName và chỉ cung cấp giá trị cho groups mà không cần viết thêm mã:

Vì vậy, hãy xoá dòng đó hoặc thêm "//" vào đầu dòng để đánh dấu dòng đó là ghi chú.

Trong Kotlin, nếu muốn kết hợp các tham số mặc định và không mặc định, chúng ta cần sử dụng các tham số có tên.

// This doesn't work...
User(104, "warlow", listOf("staff", "inactive"))
// But using named parameters, it does...
User(104, "warlow", groups = listOf("staff", "inactive"))

Lý do là Kotlin sẽ tạo các hàm nạp chồng, bao gồm cả các hàm khởi tạo, nhưng chỉ tạo một hàm nạp chồng cho mỗi tham số có giá trị mặc định.

Hãy xem lại UseCase.java và giải quyết vấn đề tiếp theo: lệnh gọi đến user.hasSystemAccess() trong phương thức UseCase.getSystemUsers():

public static List<User> getSystemUsers() {
   ArrayList<User> systemUsers = new ArrayList<>();
   for (User user : Repository.getUsers()) {
       if (user.hasSystemAccess()) {     // Now has an error!
           systemUsers.add(user);
       }
   }
   return systemUsers;
}

Đây là một lỗi thú vị! Nếu sử dụng tính năng tự động hoàn thành của IDE trên lớp User, bạn sẽ thấy hasSystemAccess() được đổi tên thành getHasSystemAccess().

Để khắc phục vấn đề này, chúng ta muốn Kotlin tạo một tên khác cho thuộc tính val hasSystemAccess. Để làm việc này, chúng ta có thể dùng chú giải @JvmName. Hãy quay lại User.kt và xem chúng ta nên áp dụng nó ở đâu.

Có 2 cách để áp dụng chú thích. Cách đầu tiên là áp dụng trực tiếp cho phương thức get(), như sau:

val hasSystemAccess
   @JvmName("hasSystemAccess")
   get() = "sys" in groups

Điều này báo hiệu cho Kotlin thay đổi chữ ký của phương thức truy cập được xác định rõ ràng thành tên được cung cấp.

Ngoài ra, bạn có thể áp dụng thuộc tính này cho thuộc tính bằng cách sử dụng tiền tố get: như sau:

@get:JvmName("hasSystemAccess")
val hasSystemAccess
   get() = "sys" in groups

Phương thức thay thế này đặc biệt hữu ích đối với những thuộc tính đang sử dụng một phương thức getter mặc định, được xác định ngầm. Ví dụ:

@get:JvmName("isActive")
val active: Boolean

Điều này cho phép thay đổi tên của phương thức getter mà không cần xác định rõ ràng phương thức getter.

Mặc dù có sự khác biệt này, bạn có thể sử dụng bất kỳ cách nào mà bạn cảm thấy phù hợp. Cả hai đều khiến Kotlin tạo một phương thức getter có tên là hasSystemAccess().

Nếu chuyển về UseCase.java, chúng ta có thể xác minh rằng getSystemUsers() hiện không có lỗi!

Lỗi tiếp theo nằm trong formatUser(), nhưng nếu bạn muốn đọc thêm về quy ước đặt tên getter của Kotlin, hãy tiếp tục đọc tại đây trước khi chuyển sang bước tiếp theo.

Đặt tên cho phương thức getter và phương thức setter

Khi viết Kotlin, bạn rất dễ quên rằng việc viết mã như:

val myString = "Logged in as ${user.displayName}")

Thực sự gọi một hàm để lấy giá trị của displayName. Chúng ta có thể xác minh điều này bằng cách chuyển đến Tools > Kotlin > Show Kotlin Bytecode (Công cụ > Kotlin > Hiện mã byte Kotlin) trong trình đơn rồi nhấp vào nút Decompile (Dịch ngược):

String myString = "Logged in as " + user.getDisplayName();

Khi muốn truy cập các phương thức này từ Java, chúng ta cần viết rõ tên của phương thức getter.

Trong hầu hết trường hợp, tên Java của các phương thức getter cho thuộc tính Kotlin chỉ đơn giản là get + tên thuộc tính, như chúng ta đã thấy với User.getHasSystemAccess()User.getDisplayName(). Một trường hợp ngoại lệ là các thuộc tính có tên bắt đầu bằng "is". Trong trường hợp này, tên Java cho phương thức getter là tên của thuộc tính Kotlin.

Ví dụ: một thuộc tính trên User, chẳng hạn như:

val isAdmin get() = //...

Sẽ được truy cập từ Java bằng:

boolean userIsAnAdmin = user.isAdmin();

Bằng cách sử dụng chú giải @JvmName, Kotlin sẽ tạo mã byte có tên được chỉ định (thay vì tên mặc định) cho mục được chú giải.

Điều này cũng áp dụng cho các setter, tên được tạo của setter luôn là set + tên thuộc tính. Ví dụ: hãy lấy lớp sau:

class Color {
   var red = 0f
   var green = 0f
   var blue = 0f
}

Hãy tưởng tượng rằng chúng ta muốn thay đổi tên phương thức setter từ setRed() thành updateRed(), trong khi vẫn giữ nguyên các phương thức getter. Chúng ta có thể sử dụng phiên bản @set:JvmName để làm việc này:

class Color {
   @set:JvmName("updateRed")
   var red = 0f
   @set:JvmName("updateGreen")
   var green = 0f
   @set:JvmName("updateBlue")
   var blue = 0f
}

Sau đó, từ Java, chúng ta có thể viết:

color.updateRed(0.8f);

UseCase.formatUser() sử dụng quyền truy cập trực tiếp vào trường để lấy giá trị của các thuộc tính của đối tượng User.

Trong Kotlin, các thuộc tính thường được hiển thị thông qua phương thức getter và setter. Trong đó có các thuộc tính val.

Bạn có thể thay đổi hành vi này bằng cách sử dụng chú giải @JvmField. Khi được áp dụng cho một thuộc tính trong một lớp, Kotlin sẽ bỏ qua việc tạo các phương thức getter (và setter cho các thuộc tính var), đồng thời có thể truy cập trực tiếp vào trường sao lưu.

Vì các đối tượng User là bất biến, nên chúng ta muốn hiển thị từng thuộc tính của các đối tượng này dưới dạng các trường. Vì vậy, chúng ta sẽ chú thích từng thuộc tính bằng @JvmField:

data class User @JvmOverloads constructor(
   @JvmField val id: Int,
   @JvmField val username: String,
   @JvmField val displayName: String = username.toTitleCase(),
   @JvmField val groups: List<String> = listOf("guest")
) {
   @get:JvmName("hasSystemAccess")
   val hasSystemAccess
       get() = "sys" in groups
}

Nếu xem lại UseCase.formatUser(), chúng ta có thể thấy rằng các lỗi đã được khắc phục!

@JvmField hoặc const

Ngoài ra, còn có một lỗi khác có dạng tương tự trong tệp UseCase.java:

Repository.saveAs(Repository.BACKUP_PATH);

Nếu sử dụng tính năng tự động hoàn thành ở đây, chúng ta có thể thấy rằng có một Repository.getBACKUP_PATH(), vì vậy, bạn có thể muốn thay đổi chú thích trên BACKUP_PATH từ @JvmStatic thành @JvmField.

Hãy thử cách này. Chuyển về Repository.kt rồi cập nhật chú giải:

object Repository {
   @JvmField
   val BACKUP_PATH = "/backup/user.repo"

Nếu xem UseCase.java ngay bây giờ, chúng ta sẽ thấy lỗi đã biến mất, nhưng cũng có một ghi chú trên BACKUP_PATH:

Trong Kotlin, các kiểu duy nhất có thể là const là các kiểu nguyên thuỷ, chẳng hạn như int, floatString. Trong trường hợp này, vì BACKUP_PATH là một chuỗi, nên chúng ta có thể đạt được hiệu suất tốt hơn bằng cách sử dụng const val thay vì val được chú thích bằng @JvmField, đồng thời vẫn giữ được khả năng truy cập vào giá trị dưới dạng một trường.

Hãy thay đổi điều đó ngay bây giờ trong Repository.kt:

object Repository {
   const val BACKUP_PATH = "/backup/user.repo"

Nếu nhìn lại UseCase.java, chúng ta có thể thấy chỉ còn một lỗi.

Lỗi cuối cùng cho biết Exception: 'java.io.IOException' is never thrown in the corresponding try block.

Tuy nhiên, nếu xem mã cho Repository.saveAs trong Repository.kt, chúng ta sẽ thấy mã này khai báo một ngoại lệ. Nội dung có vấn đề gì?

Java có khái niệm về "ngoại lệ đã kiểm tra". Đây là những trường hợp ngoại lệ có thể khắc phục được, chẳng hạn như người dùng nhập sai tên tệp hoặc mạng tạm thời không hoạt động. Sau khi bắt được một ngoại lệ đã kiểm tra, nhà phát triển có thể cung cấp thông tin phản hồi cho người dùng về cách khắc phục vấn đề.

Vì các ngoại lệ đã kiểm tra được kiểm tra tại thời điểm biên dịch, nên bạn khai báo chúng trong chữ ký của phương thức:

public void openFile(File file) throws FileNotFoundException {
   // ...
}

Mặt khác, Kotlin không có ngoại lệ đã kiểm tra và đó là nguyên nhân gây ra vấn đề ở đây.

Giải pháp là yêu cầu Kotlin thêm IOException có khả năng được truyền đến chữ ký của Repository.saveAs(), để mã byte JVM đưa chú giải này vào dưới dạng một ngoại lệ đã kiểm tra.

Chúng tôi thực hiện việc này bằng chú giải @Throws Kotlin, giúp tăng khả năng tương tác của Java/Kotlin. Trong Kotlin, các ngoại lệ hoạt động tương tự như Java, nhưng không giống như Java, Kotlin chỉ có các ngoại lệ không được kiểm tra. Vì vậy, nếu muốn thông báo cho mã Java rằng một hàm Kotlin sẽ gửi một ngoại lệ, bạn cần sử dụng chú giải @Throws cho chữ ký hàm Kotlin. Chuyển sang Repository.kt file và cập nhật saveAs() để thêm chú giải mới:

@JvmStatic
@Throws(IOException::class)
fun saveAs(path: String?) {
   val outputFile = File(path)
   if (!outputFile.canWrite()) {
       throw FileNotFoundException("Could not write to file: $path")
   }
   // Write data...
}

Với chú giải @Throws, chúng ta có thể thấy rằng tất cả các lỗi trình biên dịch trong UseCase.java đều đã được khắc phục! Thật tuyệt!

Có thể bạn thắc mắc liệu bạn có phải dùng các khối trycatch khi gọi saveAs() từ Kotlin hay không.

Không đâu. Hãy nhớ rằng Kotlin không có ngoại lệ đã kiểm tra và việc thêm @Throws vào một phương thức không thay đổi điều đó:

fun saveFromKotlin(path: String) {
   Repository.saveAs(path)
}

Bạn vẫn nên bắt các ngoại lệ khi có thể xử lý chúng, nhưng Kotlin không bắt buộc bạn phải xử lý các ngoại lệ đó.

Trong lớp học lập trình này, chúng ta đã tìm hiểu những kiến thức cơ bản về cách viết mã Kotlin, đồng thời hỗ trợ viết mã Java đúng quy ước.

Chúng ta đã thảo luận về cách sử dụng chú thích để thay đổi cách Kotlin tạo mã byte JVM, chẳng hạn như:

  • @JvmStatic để tạo thành phần và phương thức tĩnh.
  • @JvmOverloads để tạo các phương thức nạp chồng cho các hàm có giá trị mặc định.
  • @JvmName để thay đổi tên của phương thức getter và phương thức setter.
  • @JvmField để hiện một thuộc tính trực tiếp dưới dạng trường thay vì thông qua phương thức getter và phương thức setter.
  • @Throws để khai báo các trường hợp ngoại lệ đã đánh dấu.

Nội dung cuối cùng của các tệp là:

User.kt

data class User @JvmOverloads constructor(
   @JvmField val id: Int,
   @JvmField val username: String,
   @JvmField val displayName: String = username.toTitleCase(),
   @JvmField val groups: List<String> = listOf("guest")
) {
   val hasSystemAccess
       @JvmName("hasSystemAccess")
       get() = "sys" in groups
}

Repository.kt

object Repository {
   const val BACKUP_PATH = "/backup/user.repo"

   private val _users = mutableListOf<User>()
   private var _nextGuestId = 1000

   @JvmStatic
   val users: List<User>
       get() = _users

   @JvmStatic
   val nextGuestId
       get() = _nextGuestId++

   init {
       _users.add(User(100, "josh", "Joshua Calvert", listOf("admin", "staff", "sys")))
       _users.add(User(101, "dahybi", "Dahybi Yadev", listOf("staff", "nodes")))
       _users.add(User(102, "sarha", "Sarha Mitcham", listOf("admin", "staff", "sys")))
       _users.add(User(103, "warlow", groups = listOf("staff", "inactive")))
   }

   @JvmStatic
   @Throws(IOException::class)
   fun saveAs(path: String?):Boolean {
       val backupPath = path ?: return false

       val outputFile = File(backupPath)
       if (!outputFile.canWrite()) {
           throw FileNotFoundException("Could not write to file: $backupPath")
       }
       // Write data...
       return true
   }

   @JvmStatic
   fun addUser(user: User) {
       // Ensure the user isn't already in the collection.
       val existingUser = users.find { user.id == it.id }
       existingUser?.let { _users.remove(it) }
       // Add the user.
       _users.add(user)
   }
}

StringUtils.kt

@file:JvmName("StringUtils")

package com.google.example.javafriendlykotlin

fun String.toTitleCase(): String {
   if (isNullOrBlank()) {
       return this
   }

   return split(" ").map { word ->
       word.foldIndexed("") { index, working, char ->
           val nextChar = if (index == 0) char.toUpperCase() else char.toLowerCase()
           "$working$nextChar"
       }
   }.reduceIndexed { index, working, word ->
       if (index > 0) "$working $word" else word
   }
}

fun String.nameToLogin(): String {
   if (isNullOrBlank()) {
       return this
   }
   var working = ""
   toCharArray().forEach { char ->
       if (char.isLetterOrDigit()) {
           working += char.toLowerCase()
       } else if (char.isWhitespace() and !working.endsWith(".")) {
           working += "."
       }
   }
   return working
}