Android with Java: Lessons Left Over from My First App
While writing my first Android app in Java back in 2020, I fell into every trap there is — from losing form data the moment the screen rotated to leaking memory. In this post I walk through the lifecycle, context leaks, the habits left over from the AsyncTask era, and null checking, all through my own mistakes.
While writing my first Android app in Java in 2020, I turned the screen to landscape and watched everything I had typed into the form disappear. In that moment I assumed it was a bug. It wasn't; that was how the system was designed, and I didn't know it. This post is a list of the holes I fell into one by one back then.
I came from Python. There, a script runs from top to bottom and I decide where it stops. On Android I wasn't calling the code — the system was calling me. Wrapping my head around that inversion took far longer than learning Java syntax.
The lifecycle is a contract, not a suggestion
An Activity has onCreate, onStart, onResume, onPause, onStop and onDestroy callbacks. At first I read them as "the places that run at startup" and piled everything into onCreate. The result: when the user switched to another app and came back, my app showed a dead screen.
The correct reading is this: these callbacks are the moments where the system tells me "you may acquire resources" and "release them now." If I need the camera, location, or a listener, I acquire it in onStart or onResume and release it in the mirroring onStop or onPause. If I don't release it symmetrically to where I acquired it, the app keeps burning battery while it sits in the background.
The concrete incident that drilled this into me: I opened a Firestore listener in onCreate and never wrote the code to close it. As the user moved back and forth between screens, multiple copies of the same listener piled up, and a single data change made the UI do the same work over and over.
@Override
protected void onStart() {
super.onStart();
listener = db.collection("ilanlar")
.addSnapshotListener((snapshot, error) -> {
if (error != null) return;
if (snapshot == null) return;
guncelle(snapshot.getDocuments());
});
}
@Override
protected void onStop() {
super.onStop();
if (listener != null) {
listener.remove();
listener = null;
}
}
The rule is simple: if you opened something, write the callback that closes it at the same time. Don't tell yourself you'll write it later — you won't.
Configuration changes: rotating the screen recreates the app
On Android, things like a screen rotation, a language change, or switching to dark theme destroy and recreate the Activity by default. That means onDestroy followed by a fresh onCreate. Everything you were holding in field variables is wiped.
My first fix was to add android:configChanges to the manifest and block the recreation. It worked, but it was wrong: with recreation blocked, the layout files that should load for different screen sizes never kicked in either. My landscape design never showed up once.
The right answer is to keep state somewhere it won't be lost. For small, serializable data, onSaveInstanceState is enough:
@Override
protected void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
outState.putString("aramaMetni", aramaMetni);
outState.putInt("sayfa", sayfa);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_liste);
if (savedInstanceState != null) {
aramaMetni = savedInstanceState.getString("aramaMetni", "");
sayfa = savedInstanceState.getInt("sayfa", 0);
}
}
Don't put large lists, images, or an entire network response into a Bundle. The system caps that space, and if you overflow it the app crashes. Put "where the user left off" in there, not the data itself; refetch the data or read it back from the local database.
The easiest way to test this is to turn on the "don't keep activities" setting in developer options. The system kills your app the moment it goes to the background, so you see the scenario your users actually hit, every single time. The first time I turned it on, half of my app crashed — which was good news.
Context leaks: the sneakiest bug
In Java the garbage collector won't clean up an object as long as something else still holds a reference to it. On Android that is fatal for an Activity. The Activity is destroyed, but if a long-lived object holds a reference to it, that Activity and its entire view tree stay in memory.
The mistake I made most often was holding a static field:
// wrong
public class Uygulama {
public static Context context; // if an Activity is assigned here, it never dies
}
The second common mistake: inner classes. In Java, a non-static inner class carries a hidden reference to its outer class. When a Handler or a long-running task is written as an inner class, it holds on to the Activity until the work finishes — even if the user has already closed the screen.
My habit today is this: if I'm handing a context to something long-lived, I hand it getApplicationContext(); but if I'm doing UI work (showing a dialog, reading the theme, inflating a layout), I always use the Activity context. Mixing the two is its own class of bug — trying to show a dialog with the application context blows up at runtime.
A memory leak doesn't produce crashes, it produces slowness. The app starts stuttering during long sessions and eventually gets killed because it runs out of memory. Opening and closing a screen a few times in Android Studio's memory profiler and then taking a heap snapshot shows you directly how many copies of the same Activity are sitting in memory.
What's left over from the AsyncTask era
When I got into Android, AsyncTask was the first thing that came to mind for background work. Its shape was inviting: do the work in doInBackground, update the UI in onPostExecute. The problem was that it knew nothing about the lifecycle. onPostExecute would run after the Activity had closed and try to update a view that no longer existed. A share of the errors I was getting came from exactly this.
AsyncTask has been deprecated for a long time, and nobody reaches for it when writing something new. But the lesson it left behind still holds, and it's the same today when working with Kotlin coroutines or Flutter's Future: when the background work finishes, you have to ask whether the screen you're about to show the result on is still alive.
On the Java side, the practical safeguard I ended up with was checking the Activity's state before handling the result.
private void sonucGeldi(List<Ilan> ilanlar) {
if (isFinishing() || isDestroyed()) return;
adapter.setItems(ilanlar);
}
In a Fragment the situation is even subtler. The Fragment itself can be alive while its view has already been destroyed; getView() returns null and you crash if you touch it. So when handling a background result inside a Fragment, you have to check separately that the view still exists. I collected quite a few crash reports before I learned that distinction.
Null checking: Java's most expensive gap
NullPointerException was the most common cause of crashes I ran into on Android. In Java, whether a variable can be null isn't written into the type system; either you know or you don't. What Kotlin solves with a ?, you have to solve in Java with discipline.
This gets even more obvious when working with Firebase. A document coming back from Firestore may not have the field you expect; the user may have written it with an older version, or the field may never have been written at all. getString("ad") will happily return null. For a while I checked this at every single call site, and the code became unreadable.
Then I changed the approach: do the null checking at the boundary and let only clean data through. In the one place where I convert raw data from the network into a model, I fill the gaps with defaults, and from that point on nothing has to think about null.
public static Ilan fromDocument(DocumentSnapshot doc) {
Ilan ilan = new Ilan();
ilan.id = doc.getId();
String ad = doc.getString("ad");
ilan.ad = (ad != null) ? ad : "Isimsiz ilan";
Long fiyat = doc.getLong("fiyat");
ilan.fiyat = (fiyat != null) ? fiyat : 0L;
return ilan;
}
Adding @NonNull and @Nullable annotations alongside it helps too. They won't stop the compiler, but Android Studio warns you — and most of those warnings turned out to be real bugs.
What I learned from not working in a team
Because I worked alone, there was nobody doing code review. Here's how I paid for it: I wrote the same bug three different ways across three different screens, then fixed all three separately.
To compensate, I built two habits. First, whenever I fix a bug, I go look for whether the same fix is needed on the other screens. Second, I keep developer options turned on: "don't keep activities," the background process limit, animation scale off. These make real device behavior harsher than my development conditions, so the bugs surface for me rather than for the user.
Why Java is still worth learning
Most people starting a new Android project today go with Kotlin; I shipped an app in Kotlin myself and moved to Flutter in 2026. Even so, I don't regret having learned Java, for three reasons.
- What sits underneath Android still runs largely on concepts from the Java world. The lifecycle, garbage collection, the main-thread rule, Intent and Bundle — none of these disappear when the language changes.
- Most of the code that already exists is Java. When you join a company you don't start a project from scratch; you step into a codebase that has been alive for years. If you can't read it, you can't contribute to it either.
- The best way to understand the problems Kotlin solves is to have lived through them. You grasp why null safety is taken so seriously much better after spending a night chasing a
NullPointerExceptionin Java.
The same thing held for me on the Swift and Flutter side. Whatever the platform, the question doesn't change: when is this screen born, when does it die, and does it release what it was holding on the way out?
What I'd tell someone starting today
Start with the lifecycle, not the language. Put a log line in every callback of an Activity, then open the app, rotate it, send it to the background, bring it back, close it. Watch the order appear in the console with your own eyes. That ten-minute experiment teaches more than this entire post.
Then write a single screen and deliberately push it: what happens with no internet, what happens if the user hits back before the data arrives, what happens if you rotate the screen over and over. Learning to break the app is the other half of learning to build it.
My first app was not a good app. But the habits it left me — closing what you open, verifying the screen is alive before using a result, cleaning up null at the boundary — are still with me today when I write Flutter.
- Java
- Android
- Mobile Development
- Lifecycle
- Memory Management
- Firebase
Demir Taşdemir
Mobile App & Web Developer
I have been building software since 2018. I have shipped 11 apps on the App Store and Google Play; right now I am working on 6 mobile apps, 1 e-commerce platform and 1 desktop game.
Got something in the works on mobile?
I have built and shipped apps with Java, Kotlin, Swift and Flutter. If you would like to talk about your project, drop me a line.