Upgrade to Pro
— share decks privately, control downloads, hide ads and more …
Speaker Deck
Features
Speaker Deck
PRO
Sign in
Sign up for free
Search
Search
Saket Narayan - Persistence as the single sourc...
Search
droidcon Berlin
July 10, 2018
Programming
0
210
Saket Narayan - Persistence as the single source of truth
droidcon Berlin
July 10, 2018
Tweet
Share
More Decks by droidcon Berlin
See All by droidcon Berlin
Jon Markoff - Best practice for apps
droidcon_berlin_2018
0
180
Jon Markoff - Voice in the enterprise
droidcon_berlin_2018
0
54
Michael Jess - Enabling enterprise mobility with SAP
droidcon_berlin_2018
0
98
Ronen Sabag - Lean async code with Kotlin’s coroutines
droidcon_berlin_2018
0
53
Boris Farber & Nikita Kozlov - The_Build_Side_of_Android_App
droidcon_berlin_2018
0
170
Zan Markan - The state of Kotlin
droidcon_berlin_2018
0
65
Miquel Beltran - No More □ (tofu) Mastering Emoji on Android
droidcon_berlin_2018
0
120
Laurent Gasser & Jeremy Rochot - Sharing a success story - A low cost, Customer driven and co-developed Android EMM
droidcon_berlin_2018
0
240
Hoi Lam - Adding ML Kit to Android Things And some TensorFlow things
droidcon_berlin_2018
1
200
Other Decks in Programming
See All in Programming
Claude Code + Container Use と Cursor で作る ローカル並列開発環境のススメ / ccc local dev
kaelaela
10
5.3k
第9回 情シス転職ミートアップ 株式会社IVRy(アイブリー)の紹介
ivry_presentationmaterials
1
320
設計やレビューに悩んでいるPHPerに贈る、クリーンなオブジェクト設計の指針たち
panda_program
6
2.1k
MDN Web Docs に日本語翻訳でコントリビュートしたくなる
ohmori_yusuke
1
120
Quand Symfony, ApiPlatform, OpenAI et LangChain s'allient pour exploiter vos PDF : de la théorie à la production…
ahmedbhs123
0
190
Hack Claude Code with Claude Code
choplin
4
2.1k
Modern Angular with Signals and Signal Store:New Rules for Your Architecture @enterJS Advanced Angular Day 2025
manfredsteyer
PRO
0
220
データの民主化を支える、透明性のあるデータ利活用への挑戦 2025-06-25 Database Engineering Meetup#7
y_ken
0
360
High-Level Programming Languages in AI Era -Human Thought and Mind-
hayat01sh1da
PRO
0
780
なぜ適用するか、移行して理解するClean Architecture 〜構造を超えて設計を継承する〜 / Why Apply, Migrate and Understand Clean Architecture - Inherit Design Beyond Structure
seike460
PRO
3
770
0626 Findy Product Manager LT Night_高田スライド_speaker deck用
mana_takada
0
170
Hypervel - A Coroutine Framework for Laravel Artisans
albertcht
1
130
Featured
See All Featured
Put a Button on it: Removing Barriers to Going Fast.
kastner
60
3.9k
Performance Is Good for Brains [We Love Speed 2024]
tammyeverts
10
950
Learning to Love Humans: Emotional Interface Design
aarron
273
40k
Distributed Sagas: A Protocol for Coordinating Microservices
caitiem20
331
22k
The Myth of the Modular Monolith - Day 2 Keynote - Rails World 2024
eileencodes
26
2.9k
The Power of CSS Pseudo Elements
geoffreycrofte
77
5.9k
Documentation Writing (for coders)
carmenintech
72
4.9k
Building Better People: How to give real-time feedback that sticks.
wjessup
367
19k
Save Time (by Creating Custom Rails Generators)
garrettdimon
PRO
31
1.3k
GitHub's CSS Performance
jonrohan
1031
460k
[Rails World 2023 - Day 1 Closing Keynote] - The Magic of Rails
eileencodes
35
2.4k
Music & Morning Musume
bryan
46
6.6k
Transcript
Persistence as the single source of truth uncommon.is Saket Narayan
Persistence as the single source of truth uncommon.is Saket Narayan
None
Offline first apps are the best kind of apps
The best kind of apps • never make the user
wait • avoid blocking progress indicators • forgive bad data connectivity
Fyi, offline first != apps designed for cities with bad
connectivity
Fyi, offline first != apps designed for cities with bad
connectivity
Bad connectivity is everywhere Source: Unsplash
Simplifying state management is the key to designing offline first
apps
Simplifying state management is the key to designing offline first
apps
But state management is difficult
None
Source: Jake Wharton
Source: Jake Wharton
@saketme Persistence as the single source of truth
@saketme Or, PSST
Some graphs to prove my point
Time Productivity
Time Productivity
Time Productivity
None
None
None
@saketme Usecases T minus 6
@saketme Usecase: Login screen T minus 5
The usual way fun onSubmit() { api.login(username, password) .subscribe {
authToken -> database.save(authToken) startActivity(HomeScreen.intent()) } }
The usual way fun onSubmit() { api.login(username, password) .subscribe {
authToken -> database.save(authToken) startActivity(HomeScreen.intent()) } }
The usual way fun onSubmit() { api.login(username, password) .subscribe {
authToken -> database.save(authToken) startActivity(HomeScreen.intent()) } }
The usual way fun onSubmit() { api.login(username, password) .subscribe {
authToken -> database.save(authToken) startActivity(HomeScreen.intent()) } }
What if the Activity gets destroyed? fun onSubmit() { api.login(username,
password) .subscribe { authToken -> database.save(authToken) startActivity(HomeScreen.intent()) } }
What if the Activity gets destroyed? fun onSubmit() { api.login(username,
password) .takeUntil(lifecycle.onDestroy) .subscribe { authToken -> database.save(authToken) startActivity(HomeScreen.intent()) } }
Problems • No support for orientation changes
AndroidManifest.xml <activity android:name=".LoginScreen" />
AndroidManifest.xml <activity android:name=".LoginScreen" android:screenOrientation="portrait" />
Problems • No support for orientation changes • Response may
be received while minimized
The PSST way fun onSubmit() { api.login(username, password) .subscribe {
authToken -> database.save(authToken) startActivity(HomeScreen.intent()) } }
The PSST way fun onSubmit() { api.login(username, password) .subscribe {
authToken -> database.save(authToken) startActivity(HomeScreen.intent()) } }
The PSST way fun onSubmit() { authRepo.login(username, password) }
The PSST way class AuthRepo { fun login(name: String, pass:
String) { api.login(name, pass) .subscribe { authToken -> database.save(authToken) } } }
The PSST way class AuthRepo { fun login(name: String, pass:
String) { api.login(name, pass) .subscribe { authToken -> database.save(authToken) } } }
The PSST way fun onSubmit() { authRepo.login(username, password) } fun
onStart() { authRepo.userSession() .takeUntil(lifecycle.onStop) .subscribe { -> when (it) -> { is Absent -> showLoginForm() is InFlight -> showProgress() is Present -> openNextActivity()
fun onSubmit() { authRepo.login(username, password) } fun onStart() { authRepo.userSession()
.takeUntil(lifecycle.onStop) .subscribe { -> when (it) -> { is Absent -> showLoginForm() is InFlight -> showProgress() is Present -> openNextActivity() } } }
fun onSubmit() { authRepo.login(username, password) } fun onStart() { authRepo.userSession()
.takeUntil(lifecycle.onStop) .subscribe { -> when (it) -> { is Absent -> showLoginForm() is InFlight -> showProgress() is Present -> openNextActivity() } } } Observable<UserSession>
fun onSubmit() { authRepo.login(username, password) } fun onStart() { authRepo.userSession()
.takeUntil(lifecycle.onStop) .subscribe { -> when (it) -> { is Absent -> showLoginForm() is InFlight -> showProgress() is Present -> openNextActivity() } } }
fun onSubmit() { authRepo.login(username, password) } fun onStart() { authRepo.userSession()
.takeUntil(lifecycle.onStop) .subscribe { -> when (it) -> { is Absent -> showLoginForm() is InFlight -> showProgress() is Present -> openNextActivity() } } }
fun onSubmit() { authRepo.login(username, password) } fun onStart() { authRepo.userSession()
.takeUntil(lifecycle.onStop) .subscribe { -> when (it) -> { is Absent -> showLoginForm() is InFlight -> showProgress() is Present -> openNextActivity() } } }
fun onSubmit() { authRepo.login(username, password) } fun onStart() { authRepo.userSession()
.takeUntil(lifecycle.onStop) .subscribe { -> when (it) -> { is Absent -> showLoginForm() is InFlight -> showProgress() is Present -> openNextActivity() } } }
@saketme Usecase: Content details screen T minus 4
None
The usual way val onClick: { submission -> startActivity( CommentsScreen.intent(this,
submission) ) }
The usual way class CommentsScreen : Activity { fun onCreate()
{ val submission = intent.getParcelable("submission") nameView.text = submission.name votesView.text = "${submission.likes} votes" } }
The usual way class CommentsScreen : Activity { fun onCreate()
{ val submission = intent.getParcelable("submission") nameView.text = submission.name votesView.text = "${submission.likes} votes” } }
The usual way class CommentsScreen : Activity { fun onCreate()
{ val submission = intent.getParcelable("submission") nameView.text = submission.name votesView.text = "${submission.likes} votes" } }
The usual way class CommentsScreen : Activity { fun onVoteClick()
{ showProgressDialog() api.upvote(submission.id) .subscribe { newVoteCount -> hideProgressDialog() votesView.text = "$newVoteCount votes” database.save(newVoteCount) } } }
The usual way class CommentsScreen : Activity { fun onVoteClick()
{ showProgressDialog() api.upvote(submission.id) .subscribe { newVoteCount -> hideProgressDialog() votesView.text = "$newVoteCount votes” database.save(newVoteCount) } } }
The usual way class CommentsScreen : Activity { fun onVoteClick()
{ showProgressDialog() api.upvote(submission.id) .subscribe { newVoteCount -> hideProgressDialog() votesView.text = "$newVoteCount votes” database.save(newVoteCount) } } }
The usual way class CommentsScreen : Activity { fun onVoteClick()
{ showProgressDialog() api.upvote(submission.id) .subscribe { newVoteCount -> hideProgressDialog() votesView.text = "$newVoteCount votes” database.save(newVoteCount) } } }
The usual way class CommentsScreen : Activity { fun onVoteClick()
{ showProgressDialog() api.upvote(submission.id) .subscribe { newVoteCount -> hideProgressDialog() votesView.text = "$newVoteCount votes” database.save(newVoteCount) } } }
The usual way class CommentsScreen : Activity { fun onVoteClick()
{ showProgressDialog() api.upvote(submission.id) .subscribe { newVoteCount -> hideProgressDialog() votesView.text = "$newVoteCount votes” database.save(newVoteCount) } } }
Problems • UI is being updated in an ad-hoc manner
Problems • UI is being updated in an ad-hoc manner
fun onCreate() { votesView.text = “${submission.votes} votes" } fun onVoteClick() { votesView.text = "$newVoteCount votes" }
Problems • UI is being updated in an ad-hoc manner
• Needs synchronisation • Complexity compounds over time
Problems • UI is being updated in an ad-hoc manner
• Saving state manually is a pain
Saving state? fun onSaveState(outState: Bundle) { outState.putParcelable("submission", submission) } fun
onCreate(savedState: Bundle) { val submission = when { savedState == null —> intent.getParcelableExtra("submission") else —> savedState.getParcelable(“submission") } }
Saving state? fun onSaveState(outState: Bundle) { outState.putParcelable("submission", submission) } fun
onCreate(savedState: Bundle) { val submission = when { savedState == null —> intent.getParcelableExtra("submission") else —> savedState.getParcelable(“submission") } }
Saving state? fun onSaveState(outState: Bundle) { outState.putParcelable("submission", submission) } fun
onCreate(savedState: Bundle) { val submission = when { savedState == null —> intent.getParcelableExtra("submission") else —> savedState.getParcelable(“submission") } }
Problems • UI is being updated in an ad-hoc manner
• Saving state manually is a pain
The PSST way val onClick: { movie -> startActivity( CommentsScreen.intent(this,
submission) ) }
The PSST way val onClick: { movie -> startActivity( CommentsScreen.intent(this,
submission.id) ) }
The PSST way val onClick: { movie -> startActivity( CommentsScreen.intent(this,
submission.id) ) }
The PSST way fun onCreate() { val submission = intent.getParcelable(“submission”)
nameView.text = submission.name votesView.text = "${submission.likes} votes" }
The PSST way fun onCreate() { val submission = intent.getParcelable(“submission”)
nameView.text = submission.name votesView.text = "${submission.likes} votes" }
The PSST way fun onCreate() { val submissionId = intent.getString(“submission_id”)
}
The PSST way fun onCreate() { val submissionId = intent.getString(“submission_id")
submissionRepo.submission(submissionId) .takeUntil(lifecycle.onDestroy) .subscribe { submission -> votesView.text = “${submission.votes} votes” } }
The PSST way fun onCreate() { val submissionId = intent.getString(“submission_id")
submissionRepo.submission(submissionId) .takeUntil(lifecycle.onDestroy) .subscribe { submission -> votesView.text = “${submission.votes} votes” } } Observable<Submission>
The PSST way fun onCreate() { val submissionId = intent.getString(“submission_id")
submissionRepo.submission(submissionId) .takeUntil(lifecycle.onDestroy) .subscribe { submission -> votesView.text = “${submission.votes} votes” } }
The PSST way fun onCreate() { val submissionId = intent.getString(“submission_id")
submissionRepo.submission(submissionId) .takeUntil(lifecycle.onDestroy) .subscribe { submission -> votesView.text = “${submission.votes} votes” } } fun onVoteClick() { showProgressDialog()
fun onVoteClick() { showProgressDialog() api.vote(submission.id) .subscribe { newVoteCount -> hideProgressDialog()
votesView.text = "$newVoteCount votes” } }
fun onVoteClick() { showProgressDialog() api.vote(submission.id) .subscribe { newVoteCount -> hideProgressDialog()
votesView.text = "$newVoteCount votes” } }
fun onVoteClick() { submissionRepo.vote(submission.id) }
fun onCreate() { val submissionId = intent.getString(“submission_id") submissionRepo.submission(submissionId) .takeUntil(lifecycle.onDestroy) .subscribe
{ submission -> votesView.text = “${submission.votes} votes” } } fun onVoteClick() { submissionRepo.vote(submission.id) }
fun onCreate() { val submissionId = intent.getString(“submission_id") submissionRepo.comments(submissionId) .takeUntil(lifecycle.onDestroy) .subscribe
{ submission -> votesView.text = “${submission.votes} votes” } } fun onVoteClick() { submissionRepo.vote(submission.id) }
fun onCreate() { val submissionId = intent.getString(“submission_id") submissionRepo.comments(submissionId) .takeUntil(lifecycle.onDestroy) .subscribe
{ submission -> votesView.text = “${submission.votes} votes” } } fun onVoteClick() { submissionRepo.vote(submission.id) }
class SubmissionRepo { fun submission(id: String): Observable<Submission> { return database.comments(id)
} fun vote(id: String) { api.vote(id) .subscribe { updatedSubmission -> database.save(updatedSubmission) } } }
class SubmissionRepo { fun submission(id: String): Observable<Submission> { return database.comments(id)
} fun vote(id: String) { api.vote(id) .subscribe { updatedSubmission -> database.save(updatedSubmission) } } }
class SubmissionRepo { fun submission(id: String): Observable<Submission> { return database.comments(id)
} fun vote(id: String) { api.vote(id) .subscribe { updatedSubmission -> database.save(updatedSubmission) } } }
Obvious wins • UI is driven from a single source
of truth • Passing serialized data is not required • UI automatically receives updates
Obvious wins • UI is driven from a single source
of truth • Passing serialized data is not required • UI automatically receives updates • Even when changes happen outside the screen. E.g., from a notification
Improving the UX
class SubmissionRepo { fun vote(id: String) { api.vote(id) .subscribe {
updatedSubmission -> database.save(updatedSubmission) } } }
class SubmissionRepo { fun vote(id: String) { // Assume that
the network call will succeed. api.vote(id) .subscribe { updatedSubmission -> database.save(updatedSubmission) } } }
class SubmissionRepo { fun vote(id: String) { // Assume that
the network call will succeed. val update = database.submission(id) .take(1) .map { it.incrementVotesBy(1) } .andThen { save(it) } val call = api.vote(id) .andThen { save(it) } update .andThen(call) .subscribe() } }
class SubmissionRepo { fun vote(id: String) { // Assume that
the network call will succeed. val update = database.submission(id) .take(1) .map { it.incrementVotesBy(1) } .andThen { save(it) } val call = api.vote(id) .andThen { save(it) } update .andThen(call) .subscribe() } }
class SubmissionRepo { fun vote(id: String) { // Assume that
the network call will succeed. val update = database.submission(id) .take(1) .map { it.incrementVotesBy(1) } .andThen { save(it) } val call = api.vote(id) .andThen { save(it) } update .andThen(call) .subscribe() } }
Obvious wins • UI is driven from a single source
of truth • Passing serialized data is not required • UI automatically receives updates
Obvious wins • UI is driven from a single source
of truth • Passing serialized data is not required • UI automatically receives updates • Ui receives immediate feedback
If a network call does not involve any extra server
validation, assume that it’ll succeed.
@saketme Usecase: Comments screen T minus 3
Relay Sync Flamingo
Relay Sync Flamingo
None
Relay Sync Flamingo
None
Relay Sync Flamingo
None
A better experience, is to remove the notion of network
from the user’s perspective
None
class CommentsScreen { fun onCreate() { val submissionId = intent.getString("id")
submissionRepo.submission(submissionId) .takeUntil(lifecycle.onDestroy) .subscribe { populateUi(it) } } }
class CommentsScreen { fun onCreate() { val submissionId = intent.getString("id")
submissionRepo.submission(submissionId) .takeUntil(lifecycle.onDestroy) .subscribe { populateUi(it) } } }
class CommentsScreen { fun onCreate() { val submissionId = intent.getString("id")
submissionRepo.submission(submissionId) .takeUntil(lifecycle.onDestroy) .subscribe { populateUi(it) } } }
class CommentsScreen { fun onCreate() { val submissionId = intent.getString("id")
submissionRepo.submission(submissionId) .takeUntil(lifecycle.onDestroy) .subscribe { populateUi(it) } } }
class CommentsScreen { fun onCreate() { val submissionId = intent.getString("id")
submissionRepo.submission(submissionId) .takeUntil(lifecycle.onDestroy) .subscribe { populateUi(it) } } fun onSendReplyClick(parent: Comment, body: String) { submissionRepo.reply(parent, body) } }
class CommentsScreen { fun onCreate() { val submissionId = intent.getString("id")
submissionRepo.submission(submissionId) .takeUntil(lifecycle.onDestroy) .subscribe { populateUi(it) } } fun onSendReplyClick(parent: Comment, body: String) { submissionRepo.reply(parent, body) } }
class SubmissionRepo { fun reply(parent: Comment, body: String) { api.reply(parent,
body) .map { SyncStatus.Synced() } .onError(SyncStatus.Failed()) .startWith(SyncStatus.InFlight()) .subscribe { status -> database.save(Comment(parent, body, status)) } } }
class SubmissionRepo { fun reply(parent: Comment, body: String) { api.reply(parent,
body) .map { SyncStatus.Synced() } .onError(SyncStatus.Failed()) .startWith(SyncStatus.InFlight()) .subscribe { status -> database.save(Comment(parent, body, status)) } } }
class SubmissionRepo { fun reply(parent: Comment, body: String) { api.reply(parent,
body) .map { SyncStatus.Synced() } .onError(SyncStatus.Failed()) .startWith(SyncStatus.InFlight()) .subscribe { status -> database.save(Comment(parent, body, status)) } } }
class SubmissionRepo { fun reply(parent: Comment, body: String) { api.reply(parent,
body) .map { SyncStatus.Synced() } .onError(SyncStatus.Failed()) .startWith(SyncStatus.InFlight()) .subscribe { status -> database.save(Comment(parent, body, status)) } } }
class SubmissionRepo { fun reply(parent: Comment, body: String) { api.reply(parent,
body) .map { SyncStatus.Synced() } .onError(SyncStatus.Failed()) .startWith(SyncStatus.InFlight()) .subscribe { status -> database.save(Comment(parent, body, status)) } } }
class CommentViewHolder { fun onBind(comment: Comment) { bylineView.text = when(comment.syncStatus)
{ is InFlight -> “Posting..." is Synced -> "Posted at ${comment.sentTime}" is Failed -> "Failed. Tap to retry" } } }
class CommentViewHolder { fun onBind(comment: Comment) { bylineView.text = when(comment.syncStatus)
{ is InFlight -> “Posting..." is Synced -> "Posted at ${comment.sentTime}" is Failed -> "Failed. Tap to retry" } } }
class CommentViewHolder { fun onBind(comment: Comment) { bylineView.text = when(comment.syncStatus)
{ is InFlight -> “Posting..." is Synced -> "Posted at ${comment.sentTime}" is Failed -> "Failed. Tap to retry" } } }
class CommentViewHolder { fun onBind(comment: Comment) { bylineView.text = when(comment.syncStatus)
{ is InFlight -> “Posting..." is Synced -> "Posted at ${comment.sentTime}" is Failed -> "Failed. Tap to retry" } } }
Re-sending failed comments class BootCompleteReceiver : Receiver() { fun onReceive()
{ submissionRepo.sendUnsyncedComments() } }
Re-sending failed comments class RedditApp : Application() { fun onCreate()
{ submissionRepo.sendUnsyncedComments() } }
Re-sending failed comments class ReplyWorker : Worker() { fun doWork()
{ submissionRepo.sendUnsyncedComments() } }
@saketme Common excuses for avoiding Persistence T minus 2
1) SQLite is time consuming
1) SQLite is time consuming • True, plain SQLite requires
a lot of boilerplate
None
1) SQLite is time consuming • True, plain SQLite requires
a lot of boilerplate
1) SQLite is time consuming • True, plain SQLite requires
a lot of boilerplate • Use Room instead
Room is easy data class Comment(body: String, status: SyncStatus)
Room is easy @Entity data class Comment(body: String, status: SyncStatus)
Room is easy @Entity data class Comment(body: String, status: SyncStatus)
@Dao interface CommentDao { @Query("SELECT * FROM comment") fun comments(): Flowable<List<Comment>> }
Room is easy @Entity data class Comment(body: String, status: SyncStatus)
@Dao interface CommentDao { @Query("SELECT * FROM comment") fun comments(): Flowable<List<Comment>> }
2) Reading from disk is slow
2) Reading from disk is slow • No
2) Reading from disk is slow • No • Activities
take ~250ms to animate in
Persistence != SharedPreferences
@saketme PSST: Wrap up T minus 1
With PSST, State = representation of persisted data
With PSST, UI reacts to a single source of data
changes
With PSST, Support for config changes and app deaths for
free
With PSST, Offline first design out of the box
With PSST, Increased perceived speed of the app
@saketme Store github.com/NYTimes/Store T minus 0
@saketme onComplete()
[email protected]