Modern Date and Time dengan java.time di Android
API date dan time lama seperti SimpleDateFormat, Date, dan Calendar masih sering ditemukan di project Android. API tersebut mudah disalahgunakan karena mutable, memiliki perilaku yang kurang eksplisit, dan—khusus formatter lama—tidak aman digunakan bersama oleh beberapa thread.
Penggantinya adalah package java.time, yang menyediakan tipe berbeda untuk kebutuhan berbeda. Class di package ini immutable dan thread-safe sehingga lebih aman dipakai di Repository, UseCase, ViewModel, coroutine, dan UI.
Koreksi istilah: deprecated vs legacy
Tidak semua API lama memiliki status resmi @Deprecated.
| API lama | Status | Masalah utama | Pengganti yang umum |
|---|---|---|---|
android.text.format.Time | Deprecated sejak API 22 | Mutable dan API timezone lama | ZonedDateTime |
SimpleDateFormat / DateFormat | Tidak deprecated | Mutable dan tidak thread-safe | DateTimeFormatter |
java.util.Date | Class tidak deprecated; banyak constructor dan method lamanya deprecated | Mutable dan namanya seolah mewakili date, padahal menyimpan instant | Instant |
Calendar / GregorianCalendar | Tidak deprecated | Mutable, lenient, field-based, dan month lama zero-based | Tipe java.time sesuai kebutuhan |
System.currentTimeMillis() | Tidak deprecated | Sulit di-test jika dipanggil langsung dan unit hanya berupa angka | Instant + Clock |
Jadi, alasan migrasi bukan hanya warning deprecation. Alasan yang lebih penting adalah semantic clarity, immutability, thread-safety, timezone correctness, dan testability.
Mengapa SimpleDateFormat bermasalah di Android?
SimpleDateFormat menyimpan state internal yang berubah selama proses format() dan parse(). Instance yang sama tidak aman dipakai secara concurrent.
object LegacyDateFormatter {
// Berbahaya: singleton ini dapat dipanggil dari banyak thread.
private val formatter = SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss",
Locale.US,
)
fun format(date: Date): String = formatter.format(date)
}
Pada Android, object tersebut mungkin dipakai oleh beberapa coroutine atau request sekaligus:
Dispatchers.IO thread 1 ──┐
├──> instance SimpleDateFormat yang sama
Dispatchers.IO thread 2 ──┘
Akibatnya dapat berupa hasil format yang salah, parsing gagal secara acak, atau bug yang sulit direproduksi. Memindahkan pekerjaan ke Dispatchers.IO tidak membuat object mutable menjadi thread-safe.
Solusi lama adalah membuat formatter baru setiap pemanggilan, memakai synchronized, atau menyimpan formatter dalam ThreadLocal. Untuk code baru, solusi yang lebih sederhana adalah DateTimeFormatter.
private val apiFormatter =
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss")
fun format(value: LocalDateTime): String {
return value.format(apiFormatter)
}
DateTimeFormatter immutable dan thread-safe, sehingga aman disimpan sebagai top-level property atau di companion object.
Pilih tipe berdasarkan makna datanya
Kesalahan date-time biasanya terjadi karena developer memilih satu tipe untuk semua kebutuhan. Sebelum menulis code, tentukan apakah data mewakili tanggal kalender, jam dinding, atau titik absolut pada timeline.
| Tipe | Memiliki tanggal | Memiliki waktu | Offset/zone | Gunakan untuk |
|---|---|---|---|---|
LocalDate | Ya | Tidak | Tidak | Tanggal lahir, tanggal laporan |
LocalTime | Tidak | Ya | Tidak | Jam buka harian, waktu alarm lokal |
LocalDateTime | Ya | Ya | Tidak | Date-time lokal yang zone-nya belum ditentukan |
Instant | Timeline absolut | Timeline absolut | UTC | Timestamp backend, audit log, waktu dibuat |
OffsetDateTime | Ya | Ya | Fixed offset | Payload API dengan +07:00 atau Z |
ZonedDateTime | Ya | Ya | ZoneId + aturan zone | Jadwal yang terkait wilayah seperti Asia/Jakarta |
Duration | — | — | — | Jarak berbasis detik/nanodetik |
Period | — | — | — | Jarak berbasis hari/bulan/tahun kalender |
LocalDate
Mewakili tanggal tanpa jam dan timezone.
val birthday = LocalDate.of(1995, Month.JULY, 16)
val parsed = LocalDate.parse("2026-07-16")
val tomorrow = parsed.plusDays(1)
Gunakan LocalDate jika nilai tetap 16 Juli di mana pun user berada. Jangan menyimpan tanggal lahir sebagai midnight UTC karena konversi timezone dapat menggesernya ke tanggal sebelumnya atau berikutnya.
LocalTime
Mewakili jam dinding tanpa tanggal dan timezone.
val storeOpensAt = LocalTime.of(9, 0)
val reminderTime = LocalTime.parse("20:30")
LocalTime cocok untuk "setiap hari pukul 09.00". Nilai ini belum dapat dikonversi menjadi epoch sampai digabungkan dengan tanggal dan zone.
LocalDateTime
Mewakili tanggal dan jam tanpa offset atau timezone.
val appointment = LocalDateTime.of(
2026,
Month.JULY,
16,
9,
30,
)
LocalDateTime bukan timestamp absolut. 2026-07-16T09:30 dapat berarti waktu berbeda di Jakarta, Tokyo, dan London.
Gunakan hanya ketika zone memang belum ditentukan atau tidak relevan. Untuk event yang harus terjadi pada satu titik waktu yang sama di seluruh dunia, gunakan Instant.
Instant
Mewakili satu titik absolut pada timeline UTC.
val createdAt = Instant.now()
val fromEpochMillis = Instant.ofEpochMilli(1_768_000_000_000L)
val epochMillis = createdAt.toEpochMilli()
Pilihan default yang baik untuk:
createdAtdanupdatedAt- audit log
- token expiration
- timestamp database
- membandingkan urutan event
Konversikan ke timezone user hanya ketika akan ditampilkan.
val userZone = ZoneId.systemDefault()
val displayTime = createdAt.atZone(userZone)
OffsetDateTime
Mewakili date-time dengan fixed offset dari UTC.
val apiValue = OffsetDateTime.parse("2026-07-16T09:30:00+07:00")
val instant = apiValue.toInstant()
+07:00 adalah offset, bukan identitas timezone. Offset tidak menyimpan aturan historis atau perubahan daylight saving time.
Gunakan OffsetDateTime pada boundary API jika contract server mengirim ISO-8601 dengan Z atau +07:00. Untuk business logic internal yang hanya memerlukan titik waktu, normalisasikan ke Instant.
ZonedDateTime
Mewakili date-time, offset, dan ZoneId seperti Asia/Jakarta.
val jakartaZone = ZoneId.of("Asia/Jakarta")
val scheduledAt = ZonedDateTime.of(
LocalDate.of(2026, Month.JULY, 16),
LocalTime.of(9, 30),
jakartaZone,
)
Gunakan ketika aturan wilayah memang bagian dari requirement, misalnya meeting pukul 09.00 waktu kantor atau jadwal berulang berdasarkan timezone user.
val sameMomentInTokyo = scheduledAt.withZoneSameInstant(
ZoneId.of("Asia/Tokyo"),
)
Perbedaan penting:
withZoneSameInstant()mempertahankan titik waktu, lalu mengubah tampilan lokalnya.withZoneSameLocal()mempertahankan jam lokal, tetapi dapat menghasilkan titik waktu berbeda.
Duration berbeda dengan Period
Duration menghitung jarak pada timeline, sedangkan Period menghitung jarak kalender.
val timeout = Duration.ofSeconds(30)
val sessionLength = Duration.between(startInstant, endInstant)
val billingPeriod = Period.ofMonths(1)
val age = Period.between(birthday, LocalDate.now())
Satu bulan tidak selalu memiliki jumlah detik atau hari yang sama. Karena itu, jangan mengganti Period.ofMonths(1) dengan Duration.ofDays(30) jika requirement-nya mengikuti kalender.
Parsing dan formatting yang aman
Gunakan format ISO bawaan jika contract memungkinkan.
val instant = Instant.parse("2026-07-16T02:30:00Z")
val offset = OffsetDateTime.parse("2026-07-16T09:30:00+07:00")
val date = LocalDate.parse("2026-07-16")
Gunakan formatter custom hanya jika format eksternal memang berbeda.
private val displayDateFormatter = DateTimeFormatter.ofPattern(
"dd MMMM uuuu",
Locale.forLanguageTag("id-ID"),
)
val label = LocalDate.of(2026, 7, 16).format(displayDateFormatter)
// 16 Juli 2026
Kesalahan pattern yang umum
| Pattern | Makna |
|---|---|
MM | Bulan |
mm | Menit |
HH | Jam 00–23 |
hh | Jam 01–12; biasanya membutuhkan a |
uuuu | Proleptic year; pilihan aman untuk ISO date |
yyyy | Year-of-era; membutuhkan perhatian pada era |
XXX | Offset seperti +07:00 atau Z |
Untuk input dari backend, tetapkan locale secara eksplisit dan jangan mengandalkan locale device.
private val backendFormatter = DateTimeFormatter.ofPattern(
"dd-MMM-uuuu HH:mm:ss XXX",
Locale.US,
)
Untuk teks yang dilihat user, gunakan locale user dan pertimbangkan preferensi format 12/24 jam Android.
Android: timezone dan tampilan UI
Simpan instant, tampilkan dalam zone user
Arsitektur yang umum:
API / Database: Instant atau ISO-8601 timestamp
↓
Domain: Instant
↓
Presentation: ZonedDateTime dalam ZoneId user
↓
UI: String yang sudah dilokalkan
fun Instant.toDisplayText(
zoneId: ZoneId,
locale: Locale,
): String {
val formatter = DateTimeFormatter.ofPattern(
"dd MMM uuuu, HH:mm",
locale,
)
return atZone(zoneId).format(formatter)
}
Jangan mem-format timestamp menjadi String di Repository jika string tersebut hanya untuk UI. Menjaga tipe date-time sampai presentation layer membuat sorting, comparison, dan perubahan timezone tetap aman.
Device timezone dapat berubah
ZoneId.systemDefault() mengikuti default timezone proses. User dapat mengubah timezone device atau sistem dapat memperbaruinya berdasarkan lokasi/network.
Jangan menyimpan hasil ZoneId.systemDefault() selamanya jika fitur harus merespons perubahan setting. Ambil ketika dibutuhkan atau perbarui state saat aplikasi menerima perubahan konfigurasi yang relevan.
Alarm dan scheduling
Untuk jadwal absolut dari server, simpan Instant. Untuk jadwal seperti "setiap hari pukul 08.00", simpan LocalTime beserta ZoneId, lalu hitung occurrence berikutnya.
fun nextOccurrence(
time: LocalTime,
zone: ZoneId,
clock: Clock,
): ZonedDateTime {
val now = ZonedDateTime.now(clock.withZone(zone))
val today = now.toLocalDate().atTime(time).atZone(zone)
return if (today.isAfter(now)) {
today
} else {
today.plusDays(1)
}
}
Ketika menggunakan AlarmManager atau WorkManager, pahami apakah requirement mengikuti elapsed time, wall clock, atau timezone user. java.time membantu memodelkan waktunya, tetapi mekanisme scheduling Android tetap harus dipilih sesuai kebutuhan aplikasi.
Mendukung Android sebelum API 26
java.time tersedia native mulai API 26. Untuk minSdk yang lebih rendah, gunakan core library desugaring dari Android Gradle Plugin.
android {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
}
dependencies {
coreLibraryDesugaring(
"com.android.tools:desugar_jdk_libs:<version-compatible-with-AGP>",
)
}
Gunakan versi desugar_jdk_libs yang kompatibel dengan versi Android Gradle Plugin project. Tidak semua method baru dari versi Java terbaru otomatis tersedia; periksa tabel API desugaring ketika memakai method yang lebih baru.
Migrasi dari API lama
Date ke Instant
val legacyDate: Date = obtainLegacyDate()
val instant: Instant = legacyDate.toInstant()
val backToDate: Date = Date.from(instant)
Gunakan konversi ini pada boundary jika SDK pihak ketiga atau Android API lama masih membutuhkan Date.
Calendar ke ZonedDateTime
val calendar: Calendar = obtainLegacyCalendar()
val zonedDateTime = calendar.toInstant()
.atZone(calendar.timeZone.toZoneId())
SimpleDateFormat ke DateTimeFormatter
// Lama
val legacy = SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ssXXX",
Locale.US,
)
legacy.timeZone = TimeZone.getTimeZone("UTC")
// Modern: timestamp absolut
val instant = Instant.parse("2026-07-16T02:30:00Z")
// Modern: custom representation dengan offset
val formatter = DateTimeFormatter.ofPattern(
"uuuu-MM-dd'T'HH:mm:ssXXX",
Locale.US,
)
val value = OffsetDateTime.parse(
"2026-07-16T09:30:00+07:00",
formatter,
)
Jangan hanya menerjemahkan class satu per satu. Tentukan dulu makna datanya, lalu pilih tipe java.time yang tepat.
Buat code date-time mudah di-test dengan Clock
Pemanggilan Instant.now() atau LocalDate.now() langsung memakai system clock sehingga test bergantung pada waktu saat test dijalankan.
class SessionValidator(
private val clock: Clock,
) {
fun isExpired(expiresAt: Instant): Boolean {
return !expiresAt.isAfter(clock.instant())
}
}
Production:
val validator = SessionValidator(
clock = Clock.systemUTC(),
)
Test:
@Test
fun `session is expired when expiration equals current instant`() {
val now = Instant.parse("2026-07-16T02:30:00Z")
val fixedClock = Clock.fixed(now, ZoneOffset.UTC)
val validator = SessionValidator(fixedClock)
val result = validator.isExpired(expiresAt = now)
assertTrue(result)
}
Dengan Clock.fixed(), test tidak flaky dan tidak membutuhkan delay atau manipulasi waktu global.
Decision guide untuk Android developer
Gunakan aturan singkat berikut:
- Tanggal saja →
LocalDate. - Jam saja →
LocalTime. - Timestamp absolut →
Instant. - Payload API membawa
Zatau+07:00→ parse denganInstantatauOffsetDateTimesesuai contract. - Nama wilayah dan aturan timezone penting →
ZonedDateTime+ZoneId. - Date-time tanpa zone →
LocalDateTime, tetapi pastikan kehilangan zone memang disengaja. - Selisih berbasis detik →
Duration. - Selisih berbasis kalender →
Period. - Formatter reusable →
DateTimeFormatter, bukan sharedSimpleDateFormat. - Code memerlukan "waktu sekarang" → inject
Clock.
Checklist code review
- Apakah tipe yang digunakan mewakili makna data dengan tepat?
- Apakah timestamp absolut disimpan sebagai
Instantatau ISO-8601 yang memiliki offset? - Apakah
LocalDateTimedipakai tanpa asumsi timezone tersembunyi? - Apakah formatter immutable dan thread-safe?
- Apakah locale untuk format backend ditentukan secara eksplisit?
- Apakah timezone dikonversi hanya pada boundary yang tepat?
- Apakah
Clockdapat diganti pada unit test? - Apakah project dengan
minSdk < 26sudah mengaktifkan core library desugaring? - Apakah method
java.timeyang digunakan tersedia dalam versi desugaring project? - Apakah UI mengikuti locale serta preferensi 12/24 jam user?
Ringkasan
android.text.format.Timebenar-benar deprecated dan digantikan olehZonedDateTime.SimpleDateFormat,Date, danCalendartidak seluruhnya deprecated, tetapi merupakan legacy API yang lebih mudah disalahgunakan.SimpleDateFormattidak thread-safe;DateTimeFormatterimmutable dan thread-safe.- Jangan memakai satu tipe untuk semua kebutuhan date-time.
- Gunakan
Instantuntuk titik waktu absolut dan tipeLocal*untuk nilai tanpa timezone. - Gunakan
OffsetDateTimeuntuk fixed offset danZonedDateTimejika aturan wilayah penting. - Di Android sebelum API 26, aktifkan core library desugaring.
- Inject
Clockagar logic yang bergantung pada waktu dapat diuji secara deterministik.