$30 off During Our Annual Pro Sale. View Details »
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
190
Jon Markoff - Voice in the enterprise
droidcon_berlin_2018
0
64
Michael Jess - Enabling enterprise mobility with SAP
droidcon_berlin_2018
0
110
Ronen Sabag - Lean async code with Kotlin’s coroutines
droidcon_berlin_2018
0
67
Boris Farber & Nikita Kozlov - The_Build_Side_of_Android_App
droidcon_berlin_2018
0
190
Zan Markan - The state of Kotlin
droidcon_berlin_2018
0
74
Miquel Beltran - No More □ (tofu) Mastering Emoji on Android
droidcon_berlin_2018
0
130
Laurent Gasser & Jeremy Rochot - Sharing a success story - A low cost, Customer driven and co-developed Android EMM
droidcon_berlin_2018
0
270
Hoi Lam - Adding ML Kit to Android Things And some TensorFlow things
droidcon_berlin_2018
1
220
Other Decks in Programming
See All in Programming
【CA.ai #3】ワークフローから見直すAIエージェント — 必要な場面と“選ばない”判断
satoaoaka
0
250
Go コードベースの構成と AI コンテキスト定義
andpad
0
130
実は歴史的なアップデートだと思う AWS Interconnect - multicloud
maroon1st
0
210
マスタデータ問題、マイクロサービスでどう解くか
kts
0
110
Navigation 3: 적응형 UI를 위한 앱 탐색
fornewid
1
350
AIコーディングエージェント(Manus)
kondai24
0
190
Cap'n Webについて
yusukebe
0
140
愛される翻訳の秘訣
kishikawakatsumi
3
330
俺流レスポンシブコーディング 2025
tak_dcxi
14
8.9k
20 years of Symfony, what's next?
fabpot
2
370
WebRTC と Rust と8K 60fps
tnoho
2
2k
Socio-Technical Evolution: Growing an Architecture and Its Organization for Fast Flow
cer
PRO
0
360
Featured
See All Featured
Chrome DevTools: State of the Union 2024 - Debugging React & Beyond
addyosmani
9
1k
Fireside Chat
paigeccino
41
3.7k
Sharpening the Axe: The Primacy of Toolmaking
bcantrill
46
2.6k
How GitHub (no longer) Works
holman
316
140k
Six Lessons from altMBA
skipperchong
29
4.1k
Responsive Adventures: Dirty Tricks From The Dark Corners of Front-End
smashingmag
254
22k
Keith and Marios Guide to Fast Websites
keithpitt
413
23k
RailsConf 2023
tenderlove
30
1.3k
Code Reviewing Like a Champion
maltzj
527
40k
Exploring the Power of Turbo Streams & Action Cable | RailsConf2023
kevinliebholz
37
6.2k
Automating Front-end Workflow
addyosmani
1371
200k
Build The Right Thing And Hit Your Dates
maggiecrowley
38
3k
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]