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
190
Jon Markoff - Voice in the enterprise
droidcon_berlin_2018
0
57
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
60
Boris Farber & Nikita Kozlov - The_Build_Side_of_Android_App
droidcon_berlin_2018
0
180
Zan Markan - The state of Kotlin
droidcon_berlin_2018
0
67
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
260
Hoi Lam - Adding ML Kit to Android Things And some TensorFlow things
droidcon_berlin_2018
1
210
Other Decks in Programming
See All in Programming
Range on Rails ―「多重範囲型」という新たな選択肢が、複雑ロジックを劇的にシンプルにしたワケ
rizap_tech
0
6.7k
アメ車でサンノゼを走ってきたよ!
s_shimotori
0
230
なぜGoのジェネリクスはこの形なのか? Featherweight Goが明かす設計の核心
ryotaros
7
1.1k
AI Coding Meetup #3 - 導入セッション / ai-coding-meetup-3
izumin5210
0
3.4k
CSC305 Lecture 05
javiergs
PRO
0
220
CSC509 Lecture 05
javiergs
PRO
0
300
3年ぶりにコードを書いた元CTOが Claude Codeと30分でMVPを作った話
maikokojima
0
530
実践Claude Code:20の失敗から学ぶAIペアプログラミング
takedatakashi
15
5.2k
ALL CODE BASE ARE BELONG TO STUDY
uzulla
25
6.3k
AI駆動で0→1をやって見えた光と伸びしろ
passion0102
1
320
AI Agent 時代的開發者生存指南
eddie
3
1.8k
One Enishi After Another
snoozer05
PRO
0
110
Featured
See All Featured
Build The Right Thing And Hit Your Dates
maggiecrowley
37
2.9k
Making Projects Easy
brettharned
120
6.4k
Fashionably flexible responsive web design (full day workshop)
malarkey
407
66k
What’s in a name? Adding method to the madness
productmarketing
PRO
24
3.7k
Bootstrapping a Software Product
garrettdimon
PRO
307
110k
Typedesign – Prime Four
hannesfritz
42
2.8k
JavaScript: Past, Present, and Future - NDC Porto 2020
reverentgeek
52
5.6k
実際に使うSQLの書き方 徹底解説 / pgcon21j-tutorial
soudai
PRO
190
55k
Gamification - CAS2011
davidbonilla
81
5.5k
Visualization
eitanlees
149
16k
Creating an realtime collaboration tool: Agile Flush - .NET Oxford
marcduiker
34
2.3k
A better future with KSS
kneath
239
18k
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]