Mintly'i inşa etmek: Android süreci öldürmek isterken bir odak zamanlayıcısını doğru tutmak
Çalışan bir Pomodoro zamanlayıcısının, tek seferlik bir hatırlatıcıdan daha zor bir güvenilirlik sorunu var. İşte Mintly'in ön plan servisi ve duvar saati bitiş zamanıyla Doze, süreç ölümü ve ekran kapalıyken oluşan kaymayı nasıl atlattığı.
Tek seferlik bir hatırlatıcının tek yapması gereken, bir kez ve kabaca doğru zamanda tetiklenmektir. Bir odak zamanlayıcısı daha zor bir sorundur: ekran kapalıyken bile, Android süreci ortasında öldürüp yeniden başlatmaya karar verse bile, tam 25 dakika boyunca görünür ve sürekli doğru kalmak zorundadır. Bunu yanlış yaparsan ortaya çıkan sorun kaçırılmış bir bildirim değildir — “12:41 kaldı” yazan bir dakika boyunca donmuş bir zamanlayıcı ya da telefonuna bir göz attıktan sonra 25:00’a sıfırlanan bir sayaçtır.
Mintly’i inşa ederken ikisini de yaşadım. Bunları düzelten mimari şuydu: bir ön plan servisi, azalan bir sayaç yerine duvar saatine dayalı bir bitiş zamanı ve servisin yeniden çizmesine güvenmek yerine kendi geri sayımını çizen bir bildirim.
Bana dersi veren hata
İlk sürümüm bariz olandı: her saniye tıklayan, bir remainingSeconds alanını azaltan ve her tıkta güncellenmiş bir bildirim yayınlayan CoroutineWorker’a benzer bir döngü. Emülatörde kusursuz çalıştı ve gerçek bir telefonda bir gün içinde dağıldı. İki hata modu vardı:
- Birleştirilmiş tikler. Doze modu uyandırmaları toplu hale getirir. Ekran kapandıktan sonra “her saniye bir tik” döngüsü aslında her saniye çalışamaz — sistem ertelemesi yapar, sonra birden fazla tik’i patlama halinde teslim eder. Görünür geri sayım kekeler ya da atlar.
- Sessiz süreç ölümü. Sistem bellek ihtiyacı duyup uygulamanın sürecini öldürdüğünde, bellekteki
remainingSecondsalanı da onunla birlikte gitti. Yeniden başlatmada devam edecek bir şey yoktu, bu yüzden zamanlayıcı ya kayboldu ya da orijinal süresinde yeniden başladı — kullanıcının bakış açısından, beş dakikalık odak birdenbire ortadan kayboldu.
Çözüm “daha güvenilir tıklamak” değildi. Kalan zamanı hayatta kalması gereken bir durum gibi ele almayı bırakıp, her zaman yeniden hesaplanabilecek bir şey olarak ele almaktı.
Geri sayım değil, bitiş zamanı sakla
Temel değişiklik şuydu: bir oturum başladığı anda tek bir sessionEndAtMillis (duvar saati, System.currentTimeMillis() tabanlı) sakla ve geri kalan her şeyi talep üzerine bundan türet.
data class FocusSession(
val id: Long,
val ritual: RitualType,
val startedAtMillis: Long,
val endAtMillis: Long,
val state: SessionState, // RUNNING, PAUSED, COMPLETED
)
fun FocusSession.remainingMillis(nowMillis: Long = System.currentTimeMillis()): Long =
(endAtMillis - nowMillis).coerceAtLeast(0L)
Bu tek değişiklik tüm sistemi kendi kendini iyileştiren hale getiriyor. Kaç tikin atlandığı ya da sürecin bunların arasında öldürülüp yeniden başlatılıp başlatılmadığı önemli değil — remainingMillis() çağrıldığı anda doğrudur, bir döngüden biriktirilmek yerine iki zaman damgasından hesaplanır. Oturum başladığı anda Room’a kaydedilir, bu yüzden yeni bir süreç onu yeniden yükleyip tam olarak nerede durduğunu anında bilebilir.
Çoğunlukla tıklamayan bir ön plan servisi
Servisin işi küçülüyor: tamamlanmayı fark edecek kadar süreci canlı tutmak ve bildirimi makul ölçüde taze tutmak. Doğruluğun kaynağına sahip değil.
class FocusTimerService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val session = repository.activeSessionOrNull() ?: run { stopSelf(); return START_NOT_STICKY }
startForeground(NOTIFICATION_ID, buildChronometerNotification(session))
scope.launch {
while (isActive) {
val remaining = session.remainingMillis()
if (remaining <= 0L) {
repository.markCompleted(session.id)
postCompletionNotification(session)
stopSelf()
break
}
delay(15_000) // refresh cadence, not the source of truth
}
}
return START_REDELIVER_INTENT
}
}
İşin çoğunu iki ayrıntı yapıyor:
- Bildirim üzerinde
setUsesChronometer(true),chronometerCountDown = trueile başlatılmış veendAtMillis’ten hesaplanan bir taban ile. Android’in kendi bildirim sistemi, geri sayımı kendi saatini kullanarak her saniye yeniden çiziyor — benim döngüm değil. Servisim sadece her 15 saniyede bir yenilense de görünen sayı asla kekelemiyor. START_STICKYdeğil,START_REDELIVER_INTENT. Sistem servisi öldürürse, orijinal intent ile yeniden başlar veonStartCommand, bellekte tutulan herhangi bir şeye güvenmek yerine Room’danactiveSessionOrNull()’u yeniden okur. Öldürülüp yeniden başlatılan bir servis, hiç ölmemiş biriyle aynı bildirimi üretir.
Kesin alarm güvenlik ağı
Bir ön plan servisinin arka plandaki bir serviste olduğundan çok daha az öldürülme ihtimali var, ama “çok daha az ihtimal” bir garanti değil — agresif bir OEM pil yöneticisi yine de onu devre dışı bırakabilir. Bu yüzden tamamlanmanın, servisin hayatta kalmasına hiç bağlı olmayan ikinci bir yolu var: oturum başladığı anda endAtMillis için zamanlanan, Hydrame’in hatırlatıcıları için yazdığım aynı setExactAndAllowWhileIdle deseniyle çalışan bir kesin alarm. Alarm tetiklendiğinde servis hayattaysa, bu zararsız bir tekrar kontrolüdür. Servis öldürülmüşse, alarm gerçekten “Oturum tamamlandı” bildirimini zamanında yayınlayan şeydir. Kemer ve pantolon askısı ama askı sadece bir AlarmManager çağrısına mal oluyor.
Uygulamayı devam ettirmek önce neyi kontrol ediyor
Uygulama ön plana geldiğinde — yeni bir başlatmadan, bildirimden ya da çoklu görev geçişinden — odak ekranının yaptığı ilk şey herhangi bir şey çizmeden önce activeSessionOrNull()’u okumak ve remainingMillis()’i çağırmaktır. “Ben ayrılmadan önce zamanlayıcı çalışıyor muydu?” diye bayatlaşabilecek bir bayrak yok. Ya Room’da zamanı kalan aktif bir oturum var ve arayüz tam olarak onu gösteriyor, ya da yok ve ritüel seçiciyi gösteriyor. Zamanlayıcı kendisiyle çelişemez çünkü cevabı hesaplamasına izin verilen tek bir yer var.
Genel ders
Bu desen odak zamanlayıcılarının ötesine genelleniyor: ne zaman canlı bir geri sayımı değişebilir bellek içi durum olarak tutmaya kalksanız, bunun yerine sabit bir bitiş zaman damgası ve “şimdi”nin saf bir fonksiyonunu koyun. Tikler doğruluğun kaynağı olmaktan çıkıp bir yenileme ritmi haline gelir, bu da atlanan tiklerin, öldürülen süreçlerin ve Doze modu toplu işlemesinin doğruluk hatası olmaktan çıkması demektir — bunlar sadece kimsenin soruyu sormadığı anlardır ve bir dahaki sefere biri sorduğunda cevap yine doğrudur.
// İlgili okumalar
Günlükten dahası
Subly'yi Geliştirmek: abonelik yenileme tarihlerinin arkasındaki takvim matematiği
Bir aboneliğin sonraki ödeme tarihini tahmin etmek, ay sonu faturalandırmaya, artık yıllara ve deneme sürümü dönüşümlerine çarpana kadar önemsiz görünür. Subly'nin bunu cihaz üzerinde nasıl doğru yaptığı burada.
Android bildirim eylemleri doğru şekilde: uygulamayı açmadan bir dozu alındı olarak işaretlemek
Android bildirim eylem düğmeleri için pratik bir rehber — PendingIntent'ler, trambolin kısıtlaması, goAsync() ve Room durumunu güvenle güncellemek.
2026'da güvenilir Android hatırlatıcıları: WorkManager, kesin alarmlar ve yeni pil kuralları
2026'da Android'de gerçekten tetiklenen hatırlatıcılar nasıl yayınlanır — WorkManager mı AlarmManager mı, SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS ve hâlâ can yakan OEM tuhaflıkları.