1. Overview

Working with dates and times is a traditionally tricky task. Kotlin has several ways to help us deal with dates and times. This specifically includes the official Kotlin extension library for handling dates and times.

In this tutorial, we’ll look at the kotlinx-datetime library, which simplifies working with dates and times.

2. Library Design

The kotlinx-datetime library is based on the ISO 8601 international standard, meaning it represents date and time in the following order: year, month, day, hour, minutes, seconds, and milliseconds (yyyy-mm-dd hh:mm:ss:ssz). It focuses on common problems while working with dates and times, such as parsing, formatting, and arithmetic operations.

kotlix-datetime is a multiplatform library, so we can write the same code and use it across multiple platforms. The implementation of kotlinx-datetime date and time types like Instant, LocalDateTime, and TimeZone relies on platform libraries:

In this tutorial, we’ll look at the Java Virtual Machine (JVM) implementation. We’ll only import date-time classes from the kotlinx.datetime package, but these classes leverage java.time classes internally.

2.1. Application Setup

Let’s start by importing the kotlinx-datetime library in our application. It’s important to note that we need the platform-specific JVM version:

<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-datetime-jvm</artifactId>
    <version>0.6.0</version>
</dependency>

The library is published to Maven Central and is compatible with the Kotlin Standard Library version 1.9.0 or higher. This library is still experimental, so the API is subject to change.

2.2. Date and Time Types

The types for the library are pretty similar to the Java 8 Date Time API, and the core types for this library include:

  • Clock is a utility interface used to obtain the current Instant.
  • Instant represents a timestamp on the UTC-SLS (UTC with Smoothed Leap Seconds) time scale. It’s recommended for events that happened in the past or a well-defined moment soon, like an event expiration that will occur one hour from now.
  • LocalDateTime represents date and time without a time zone. This measurement is better for events scheduled in the far future, like a scheduled e-mail that should be sent on a specific date. We’ll need to keep track of the TimeZone or FixedOffsetTimeZone separately. LocalDateTime is also used to decode an Instant for displaying to the user in their local time.
  • LocalDate represents the date (year, month, and day) only. This represents the date of an event without a specific time, like a birth date.
  • LocalTime represents the time (hour, minutes, seconds, and nanoseconds) only. This stores the time of an event without a specific date.
  • DateTimePeriod* and *DatePeriod represent the duration between two Instants or LocalDates, respectively.

3. Basic Types Usage

Now that we know the basic types, let’s see how we can use them in our application.

3.1. Using Clock

Again, it’s important to note that we’ll be importing from the kotlinx.datetime package only. Clock is the primary entry point to this library, and we can use the System object from the Clock to get an Instant for the current moment in time:

val instant = Clock.System.now()

Using the System object queries the platform-specific system clock as its source.

3.2. Using Instant

We can also convert the number of milliseconds from the Unix Epoch to an Instant or get the Epoch milliseconds from an Instant:

val instant = Instant.fromEpochMilliseconds(epochMilliseconds = 1722475458286)
val epochMilliseconds = instant.toEpochMilliseconds()

It’s interesting to note that both an Instant and milliseconds from the Epoch represent a point on the timeline, but neither is well-suited for human readability.

3.3. Using LocalDateTime

Instant is a high-resolution measurement that uses epoch date time that is not human readable. We can combine the Instant with a TimeZone to get the LocalDateTime:

val instant = Instant.fromEpochSeconds(1722427200)
assertEquals("2024-07-31T12:00:00Z", instant.toString())
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) // System is in UTC
assertEquals("2024-07-31T12:00", localDateTime.toString())

In the above example, we created an instance of LocalDateTime with the system default TimeZone, which is UTC. Even though we can get a human-readable String from an Instant, combining it with a TimeZone is the correct way to get a representation for the client side.

TimeZone is frequently an issue, especially in distributed applications. We should store time representations without a specific time zone. So, kotlinx-datetime makes it easy to combine Instant and TimeZone to get the LocalDateTime.

We can infer the TimeZone from the string description or UTC reference. Let’s write a test asserting that both “Brazil/East” and “UTC-3” represent the same time zone:

@Test
fun `compare two LocalDateTime from different from the same TimeZone`() {
    val kotlinxDateTimeOperations = KotlinxDateTimeOperations()
    val instant = Instant.fromEpochSeconds(1722427200)
    val localDateTimeBrazil =
      kotlinxDateTimeOperations.getLocalDateTimeBrazilTimeZoneFromInstant(instant = instant)
    assertEquals("2024-07-31T09:00", localDateTimeBrazil.toString())
    val localDateTimeUtcMinus3 =
      kotlinxDateTimeOperations.getLocalDateTimeUtcMinus3TimeZoneFromInstant(instant = instant)
    assertEquals("2024-07-31T09:00", localDateTimeUtcMinus3.toString())
}

If we have the individual components, like from a user input, we can use them to create a LocalDateTime instead of an Instant:

val localDateTime = LocalDateTime(
                      year = 2024,
                      month = Month.JULY,
                      dayOfMonth = 31,
                      hour = 11,
                      minute = 10,
                      second = 0,
                      nanosecond = 0)

3.4. Using LocalDate

There are two ways to create a LocalDate. We can either declare the day, month, and year, or we can convert an Instant to a LocalDate:

val localDate = LocalDate(year = 2024, month = Month.JULY, dayOfMonth = 31)
val instant = Clock.System.now()
val localDate = instant.toLocalDateTime(TimeZone.currentSystemDefault()).date

LocalDate is simpler than LocalDateTime — it’s just day, month, and year. To get a LocalDate from an Instant, we need to convert it to a LocalDateTime first. This is a good way to avoid time zone and daylight saving time issues.

3.5. Using LocalTime

LocalTime can be created using hours, minutes, and, optionally, seconds and nanoseconds:

val hourMinute = LocalTime(hour = 10, minute = 30)
val localTimeSeconds = LocalTime(hour = 10, minute = 30, second = 15)
val localTimeNanoseconds = LocalTime(hour = 10, minute = 30, second = 15, nanosecond = 50)

There’s also a time property in the LocalDateTime that returns a LocalTime:

val instant = Clock.System.now()
val localTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()).time

4. String Conversion

String conversion is a useful feature from Date/Time libraries when we need to parse Strings from files or user inputs. Using the parse() function, we can create Instant, LocalDateTime, LocalDate, or LocalTime instances from an ISO 8601 string. For an Instant, the “Z” at the end of the string represents the UTC timezone:

val instant = Instant.parse("2024-07-31T22:19:44.475Z")

For LocalDateTime, we don’t use any timezones, and both seconds and nanoseconds are optional:

val localDateTime = LocalDateTime.parse("2024-07-31T22:19")

The LocalDate is just a year, month, and day of a timestamp:

val localDate = LocalDate.parse("2024-07-31")

Similarly to the LocalDateTime, LocalTime is just the hours and minutes, seconds, and nanoseconds of a timestamp:

val localTime = LocalTime.parse("10:19:22.111")

4.1. Custom Format

Different applications may have many ways to represent date and time with Strings. So, it’s important that we know how to parse any custom String.

There are Format() helpers in LocalDateTime, LocalDate, and LocalTime because these are all “human readable” classes, however, Instant doesn’t have a Format helper.

We can use the Format class on these date and time classes to create custom formatting instructions for parsing and printing:

val dateFormat = LocalDate.Format {
    dayOfMonth()
    char('/')
    monthNumber()
    char('/')
    year()
}
val localDate = LocalDate.parse(input = "31/07/2024", format = dateFormat)

The DateFormat we created above can also be used to format printing:

assertEquals("31/07/2024", localDate.format(dateFormat))

4.2. Incomplete Dates and Times

There may be times when the String we’re parsing doesn’t fully correspond to any of the classes we saw. For these cases, we can use the DateTimeComponents class. It has all the LocalDateTime attributes, and we can create it with as many or as few attributes as we need:

val monthDay = DateTimeComponents.Format {
    dayOfMonth()
    char('-')
    monthName(MonthNames.ENGLISH_FULL)
}.parse("31-July")
assertEquals("7", monthDay.monthNumber.toString())
assertEquals("JULY", monthDay.month.toString())
assertEquals("31", monthDay.dayOfMonth.toString())

Using DateTimeCompoents, we can extract any date and time components as needed.

5. Date and Time Arithmetic Operations

The Duration class from the Kotlin standard library represents an amount of time between two time measurements. To obtain a Duration, we can subtract two Instants:

val instant = Instant.parse("2024-07-31T22:00:00.000Z")
val olderInstant = Instant.parse("2024-03-15T22:00:00.000Z")
val duration = instant - olderInstant
assertEquals(138, duration.inWholeDays)
assertEquals(11923200000000000, duration.inWholeNanoseconds)

This is the simplest way to get the difference between Instants.

5.1. Calendar Difference

If we need a calendar difference between two Instants, there’s the periodUntil() function that returns a DateTimePeriod:

val instant = Instant.parse("2024-07-31T22:00:00.000Z")
val olderInstant = Instant.parse("2022-03-15T12:05:01.050Z")
val dateTimePeriod = olderInstant.periodUntil(instant, TimeZone.UTC)
assertEquals(2, dateTimePeriod.years)
assertEquals(4, dateTimePeriod.months)
assertEquals(16, dateTimePeriod.days)
assertEquals(9, dateTimePeriod.hours)
assertEquals(54, dateTimePeriod.minutes)
assertEquals(58, dateTimePeriod.seconds)
assertEquals(950000000, dateTimePeriod.nanoseconds)

This allows us to determine that there are 2 years, 4 months, 16 days, 9 hours, 54 minutes, 58 seconds, and 950,000,000 nanoseconds between our two instants.

5.2. Time Unit Difference

We can also calculate differences in a specific time unit using the until() function with the DateTimeUnit we need:

val instant = Instant.parse("2024-07-31T22:00:00.000Z")
val olderInstant = Instant.parse("2024-03-15T22:00:00.000Z")
val months = olderInstant.until(instant, DateTimeUnit.MONTH, TimeZone.UTC)
assertEquals(4, months)

We can achieve the same result using the monthsUntil() function, which is a shortcut for the until() with DateTimeUnit.MONTH:

val instant = Instant.parse("2024-07-31T22:00:00.000Z")
val olderInstant = Instant.parse("2024-03-15T22:00:00.000Z")
val monthsUntil = olderInstant.monthsUntil(instant, TimeZone.UTC)
assertEquals(4, monthsUntil.toLong())

There’s a similar approach for years and days with yearsUntil() and daysUntil() shortcut functions, respectively.

5.3. Plus and Minus Functions

We can add or subtract any DateTimeUnit from both Instant and LocalDate using the plus() and minus() functions, respectively:

val instant = Clock.System.now()
val tenDaysFromNow: Instant = instant.plus(10, DateTimeUnit.DAY, TimeZone.UTC)
val tenDaysBeforeNow: Instant = instant.minus(10, DateTimeUnit.DAY, TimeZone.UTC)
val localDate = LocalDate(year = 2024, month = Month.JULY, dayOfMonth = 31)
val oneYearFromLocalDate = localDate.plus(1, DateTimeUnit.YEAR)
val oneYearBeforeLocalDate = localDate.minus(1, DateTimeUnit.YEAR)

For the LocalDateTime, there are no functions for arithmetic operations. Because LocalDateTime doesn’t have a defined time zone, we can’t account for the proper timezone-specific rules that could be associated with the timestamp when performing arithmetic such as daylight saving time, so these operations are omitted instead. Specifically, an arithmetic operation can result in an invalid LocalDateTime for a specific TimeZone.

The recommended way to handle these operations is to convert to an Instant first, which also requires assigning a timezone:

val localDateTime = LocalDateTime(
                      year = 2024,
                      month = Month.JULY,
                      dayOfMonth = 31,
                      hour = 11,
                      minute = 10,
                      second = 0,
                      nanosecond = 0)
val timeZone = TimeZone.of("Brazil/West")
val instant = localDateTime.toInstant(timeZone)
val fiveMinutesAfter = instant.plus(5, DateTimeUnit.MINUTE)
val localDateTimeFiveMinutesAfter = fiveMinutesAfter.toLocalDateTime(timeZone)

This allows us to start with a LocalDateTime but still perform arithmetic operations on the timestamp.

6. Conclusion

In this article, we explored the kotlinx-datetime library. We looked at using all the main types, parsing to and from Strings, and handling arithmetic operations through several examples.

It’s important to note that this library is still experimental, meaning “use at your own risk and expect migration issues”. That being said, it’s still a powerful utility for working with dates and times in a native Kotlin way.


原始标题:Guide to the KotlinX Date/Time Library