I’ve built a game app and it uses Cloud Firestore to store user data locally.
The application’s business logic is very simple regarding the data persistence methodology. Whenever a user does something with his data, I simply save his data, whenever any activity calls onPause()
I also save his data.
I only use Source.CACHE
as a Source
parameter in every single saving procedure.
At this state of development cloud data persistence is not important, so as of right now I’m only interested in offline, device-level data persistence.
So in short: Every save method saves user data to the device.
About the error:
I’ve tested the application with 50 users for six months and never seen this issue happening, but when I decided to go open beta a few users started to report the very same issue, which is:
The application seemingly rolls back to old data state when the user reopens the app.
I would like to show you the save and load methods so you can see there is no rocket science here, I’m trying to achieve something very simple. Whenever the user does something which alters his data state, I would call the save method. Whenever the user opens the application, I would load back his data with my load method.
Loading method:
//I use this class to load a character
public class CharacterLoader implements FetcherInterface {
String documentId; //The document id of the character
String accountId; //The account id of the user
Source source = Source.CACHE; //Always, and always Source.CACHE. Never loading from the cloud.
//My very simple constructor
public CharacterLoader(String accountId, String documentId) {
this.documentId = documentId;
this.accountId = accountId;
}
//This method basically launch the loading procedure
@Override
public void go(DbResultListener dbResultListener) {
FirebaseFirestore db = FirebaseFirestore.getInstance();
//Building the query to fetch the correct character by its documentId
Query query = db.collectionGroup("localData").whereEqualTo("documentId", documentId);
query.get(this.source).addOnSuccessListener(new OnSuccessListener<QuerySnapshot>() {
@Override
public void onSuccess(QuerySnapshot queryDocumentSnapshots) {
//If the loading was successful but there is no such character
if (queryDocumentSnapshots.getDocuments().size() == 0) {
dbResultListener.onError("Error - There was an error during loading the character. Please try again.");
} else {
//Loading was successful
DocumentSnapshot documentSnapshot = queryDocumentSnapshots.getDocuments().get(0);
Chara chara = documentSnapshot.toObject(Chara.class);
if (chara == null) {
//Character was null for some reason
dbResultListener.onError("Error - Character not found!");
} else {
//If the character found, I pass it to a listener callback so my app can use it
dbResultListener.onSuccess(chara);
}
}
}
}).addOnFailureListener(new OnFailureListener() {
@Override
public void onFailure(@NonNull Exception e) {
//If ther was an error
e.printStackTrace();
dbResultListener.onError("Error on character loading: "+e.getMessage());
}
});
}
}
Saving method:
//I use this class to save character
public class CharacterSaver implements FetcherInterface {
Chara chara; //The data entity which we save
String accountId; //User's account id
String baseLink; //Link/path to my Cloud Firestore storage
//A very simple constructor, it stops the app with a runtime error if the data parameter is null.
public CharacterSaver(String baseLink, String accountId, Chara chara) {
if (chara == null) {
throw new RuntimeException("Chara object was null in parameter, cannot save!");
}
this.accountId = accountId;
this.chara = chara;
this.baseLink = baseLink;
}
//This is my save method
@Override
public void go(DbResultListener dbResultListener) {
FirebaseFirestore db = FirebaseFirestore.getInstance();
//Getting the document reference on the correct path with character's document id
DocumentReference docRef = db.collection(baseLink).document(accountId).collection("localData").document(chara.getDocumentId());
docRef.set(chara).addOnSuccessListener(new OnSuccessListener<Void>() {
@Override
public void onSuccess(Void aVoid) {
//Saving was successful, noting to do.
}
}).addOnFailureListener(new OnFailureListener() {
@Override
public void onFailure(Exception e) {
e.printStackTrace();
//Passing the error with a listener to the application
dbResultListener.onError("There was an error during saving character: " + e.getLocalizedMessage());
}
});
}
}
And now please watch the video I have about this error. A very kind and eager user successfully made a screen recording about the exact moment this bug occurs, please watch the video with sound because he narrates what is going on:
You can watch the error explaining video here.
Watch closely, whenever the user exits the application in the regular way, everything is just fine, but whenever he uses the "app history/go to background" button of his device, then reopens the application the rollback occurs!
My thoughts about the issue:
So let’s assume I made a mistake and I do not call my save method everywhere where I should, and let’s assume I have a data state loss. Well that wouldn’t explain the issue, because as you see in the video the application goes back to an OLDER STATE after it had a CORRECT, up to date state.
So it is not just a missed saving, thus state loss, it is definitely a ROLLBACK.
I have absolutely no clue about how is even this suppose to happen, I couldn’t even write this even if I’ve wanted to. There is absolutely nothing in my business logic which would justify this behavior, no method for rollbacks. Just a simple save-load methodology.
What I’ve tried so far:
- Logged through the whole application with Firebase Crashalytics, looking for null pointer exceptions, empty data states.
- Logged the
onError()
callbacks, looking for something, anything which would tell me more about this. - Logged version numbers for characters, watching closely whether a load is loading back an older character somehow.
- Logged date/time of saves/loads.
- Added a load method directly after my save method to check if I do get the correct, up to date data or not.
- Made a copy constructor for my data (
Chara
class) maybe the error somehow (I don’t know how) was caused by passing the reference instead of building a copy of the data. - Tried to replicate the error with all of the above while having internet access and also while not having internet access.
- Tried to replicate the error on: Xiaomi Mi A1, Pixel 7, Samsung A04, and on various emulators with various Android versions, I just couldn’t.
The annoying thing is, some users have hundreds of hours in the game and they never once experienced this, while others have this issue like daily.
I just don’t know where to search more for solution, I’ve read the whole internet seeking for an answer, I don’t even know how could this happen since I do nothing complicated here. One simple save method for every user interaction and one load method when the application starts, so please… Please if you have any insight about this, don’t hold it back.
Thanks in advance.
E D I T:
As requested, here is my onPause()
method, I do not override the onDestroy()
method, nothing extraordinary here as well:
@Override
protected void onPause() {
super.onPause();
CharacterSaversaver saver = new CharacterSaver(Server.getUsersUrl(this), Identifiers.getAccountIdentifier(this), getChara());
saver.go(new DbResultListener() {
@Override
public void onError(String error) {
DialogHelper.show(BaseActivity.this, error);
}
});
}
The above mentioned saving class simply gets the character instance from static memory like this:
public static Chara getChara() {
return charaPlayingStaticInstance;
}
2
Answers
source
Please see the parts with BOLD, firestore will ay consider your changes on cache as incomplete or outdated, so you should have a snapshot listener with
MetadataChanges.INCLUDE
so the changes on the cache would be considered as new values.so your code should look like something like this, (this is just a code snippet) :
UPDATE: Firestore will use Snapshot by default if you have offline caching:
I might be wrong but this issue could be related to the Android activity lifecycle and how you handle the saving process in the
onPause()
method.Firstly, you can try saving data in the
onPause()
method, consider using theonStop()
method.For example –
Secondly, in
onStop()
, you can check whether the activity is finishing before saving the data. If the activity is finishing, it means the user is navigating away from the activity, and it’s a suitable time to save data.For example –
Also verify that the
Chara
instance you are saving is correctly initialized and contains the expected data.Additinally for more control on the background/foreground transitions, you might want to consider using a library like ProcessLifecycleOwner in Android Architecture Components.
For example –
Let me know if that works.