Firebase & Jetpack: fit like a glove (DroidCon NYC)
Join this session to get a deep dive into the use of Jetpack’s Android Architecture Components along with Firebase to keep your app’s data fully synchronized and automatically refreshed with live updates from Realtime Database and Firestore.
@CodingDoug A simple example … or is it? val firestore = FirebaseFirestore.getInstance() val ref = firestore.collection("coll").document("id") ref.get() .addOnSuccessListener { snapshot -> // We have data! Now what? } .addOnFailureListener { exception -> // Oh, snap } How should I do async programming? Do I directly update my views here? What manages this singleton?
@CodingDoug Handle stock price changes from a document in Firestore val firestore = FirebaseFirestore.getInstance() val ref = firestore.collection("stocks-live").document("HSTK")
@CodingDoug Handle stock price changes from a document in Firestore val firestore = FirebaseFirestore.getInstance() val ref = firestore.collection("stocks-live").document("HSTK") ref.addSnapshotListener { snapshot, exception -> if (snapshot != null) { val model = StockPrice( ticker = snapshot.id, price = snapshot.getDouble("price")!!.toFloat() ) } else if (exception != null) { TODO("This is just a simple code sample, ain't got time for this.") } }
@CodingDoug Handle stock price changes from a document in Firestore val firestore = FirebaseFirestore.getInstance() val ref = firestore.collection("stocks-live").document("HSTK") ref.addSnapshotListener { snapshot, exception -> if (snapshot != null) { val model = StockPrice( ticker = snapshot.id, price = snapshot.getDouble("price")!!.toFloat() ) someTextView.text = model.ticker } else if (exception != null) { TODO("This is just a simple code sample, ain't got time for this.") } }
@CodingDoug Handle stock price changes from a document in Firestore val firestore = FirebaseFirestore.getInstance() val ref = firestore.collection("stocks-live").document("HSTK") ref.addSnapshotListener { snapshot, exception -> if (snapshot != null) { val model = StockPrice( ticker = snapshot.id, price = snapshot.getDouble("price")!!.toFloat() ) someTextView.text = model.ticker throw PoorArchitectureException("let's rethink mixing data store and views”) } else if (exception != null) { TODO("This is just a simple code sample, ain't got time for this.") } }
@CodingDoug @CodingDoug LiveData Observable Code can register observers to receive updates. Genericized Subclasses must specify a type of object containing updates. Lifecycle-aware Handles component start, stop, and destroy states automatically.
@CodingDoug Keep views up to date with LiveData class MyActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.stuff) val tickerTextView = findViewById(R.id.tv_ticker) val priceTextView = findViewById(R.id.tv_price) } }
@CodingDoug Keep views up to date with LiveData class MyActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.stuff) val tickerTextView = findViewById(R.id.tv_ticker) val priceTextView = findViewById(R.id.tv_price) val liveData: LiveData = getLiveDataForMyTicker("HSTK") } }
@CodingDoug Keep views up to date with LiveData class MyActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.stuff) val tickerTextView = findViewById(R.id.tv_ticker) val priceTextView = findViewById(R.id.tv_price) val liveData: LiveData = getLiveDataForMyTicker("HSTK") liveData.observe(this@MyActivity, Observer { stockPrice -> }) } }
@CodingDoug LiveData is aware of the Android Activity lifecycle override fun onStop() { super.onStop() // LivaData becomes inactive, doesn't notify observers } override fun onStart() { super.onStart() // LivaData becomes active again, notifies observers with latest data }
@CodingDoug LiveData is aware of the Android Activity lifecycle override fun onStop() { super.onStop() // LivaData becomes inactive, doesn't notify observers } override fun onStart() { super.onStart() // LivaData becomes active again, notifies observers with latest data } override fun onDestroy() { super.onDestroy() // LiveData removes all activity-scoped observers - no leaks! }
@CodingDoug Implement LiveData with Firestore document updates override fun onEvent(snap: DocumentSnapshot?, e: FirebaseFirestoreException?) { if (snap != null && snap.exists()) { val model = StockPrice( snap.id, snap.getDouble("price")!!.toFloat() ) // Here you go, all my admiring observers! Go update your UI! setValue(model) } }
@CodingDoug Implement LiveData with Firestore document updates override fun onEvent(snap: DocumentSnapshot?, e: FirebaseFirestoreException?) { if (snap != null && snap.exists()) { val model = StockPrice( snap.id, snap.getDouble("price")!!.toFloat() ) // Here you go, all my admiring observers! Go update your UI! setValue(model) } else if (e != null) { TODO("You should handle errors. Do as I say, not as I do.") } }
@CodingDoug ● Who creates the instance of LiveData? ○ StockPriceLiveData constructor exposes Firestore implementation details. ○ Remember: views should know nothing of data store. ● LiveData loses its data on configuration change. ○ Would rather have immediate LiveData results after configuration change. Two problems to resolve
@CodingDoug @CodingDoug ViewModel Survives configuration changes Same ViewModel instance appears in reconfigured activities Shared & Managed System manages the scope of instances, may be shared Lifecycle-aware Automatically cleaned up on final destroy
@CodingDoug Implement a ViewModel that yields a LiveData class StocksViewModel : ViewModel() { fun getStockPriceLiveData(ticker: String): StockPriceLiveData { } }
@CodingDoug Implement a ViewModel that yields a LiveData class StocksViewModel : ViewModel() { // Find a repository object using dependency injection private val repository: StockDataRepository = ... fun getStockPriceLiveData(ticker: String): StockPriceLiveData { } }
@CodingDoug Implement a ViewModel that yields a LiveData class StocksViewModel : ViewModel() { // Find a repository object using dependency injection private val repository: StockDataRepository = ... fun getStockPriceLiveData(ticker: String): StockPriceLiveData { } } // StockDataRepository knows where the data actually comes from interface StockDataRepository { fun getStockPriceLiveData(ticker: String): StockPriceLiveData }
@CodingDoug Implement a ViewModel that yields a LiveData class StocksViewModel : ViewModel() { // Find a repository object using dependency injection private val repository: StockDataRepository = ... fun getStockPriceLiveData(ticker: String): StockPriceLiveData { val liveData = repository.getStockPriceLiveData(ticker) return liveData } } // StockDataRepository knows where the data actually comes from interface StockDataRepository { fun getStockPriceLiveData(ticker: String): StockPriceLiveData }
@CodingDoug Implement a ViewModel that yields a LiveData class StocksViewModel : ViewModel() { // Find a repository object using dependency injection private val repository: StockDataRepository = ... private val cache = HashMap() fun getStockPriceLiveData(ticker: String): StockPriceLiveData { val liveData = repository.getStockPriceLiveData(ticker) // Cache liveData in the HashMap - too much boring code to show return liveData } } // StockDataRepository knows where the data actually comes from interface StockDataRepository { fun getStockPriceLiveData(ticker: String): StockPriceLiveData }
@CodingDoug Implement a repository backed by Firestore interface StockDataRepository { fun getStockPriceLiveData(ticker: String): StockPriceLiveData } class FirestoreStockDataRepository : StockDataRepository { // Should use DI for this too private val firestore = FirebaseFirestore.getInstance() override fun getStockPriceLiveData(ticker: String): StockPriceLiveData { } }
@CodingDoug Implement a repository backed by Firestore interface StockDataRepository { fun getStockPriceLiveData(ticker: String): StockPriceLiveData } class FirestoreStockDataRepository : StockDataRepository { // Should use DI for this too private val firestore = FirebaseFirestore.getInstance() override fun getStockPriceLiveData(ticker: String): StockPriceLiveData { val ref = firestore.collection("stocks-live").document(ticker) return StockPriceLiveData(ref) } }
@CodingDoug Use ViewModel and LiveData in an activity class MyActivity : AppCompatActivity() { private lateinit var tickerTextView: TextView private lateinit var priceTextView: TextView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Redacted: inflate and initialize views... } }
@CodingDoug class MyActivity : AppCompatActivity() { private lateinit var tickerTextView: TextView private lateinit var priceTextView: TextView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Redacted: inflate and initialize views... val viewModel = ViewModelProviders.of(this).get(StocksViewModel::class.java) } } Use ViewModel and LiveData in an activity
@CodingDoug Use ViewModel and LiveData in an activity class MyActivity : AppCompatActivity() { private lateinit var tickerTextView: TextView private lateinit var priceTextView: TextView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Redacted: inflate and initialize views... val viewModel = ViewModelProviders.of(this).get(StocksViewModel::class.java) val liveData = viewModel.getStockPriceLiveData("HSTK") } }
@CodingDoug Use ViewModel and LiveData in an activity class MyActivity : AppCompatActivity() { private lateinit var tickerTextView: TextView private lateinit var priceTextView: TextView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Redacted: inflate and initialize views... val viewModel = ViewModelProviders.of(this).get(StocksViewModel::class.java) val liveData = viewModel.getStockPriceLiveData("HSTK") liveData.observe(this, Observer { stockPrice -> }) } }
@CodingDoug ● LiveData doesn’t propagate errors ● Errors need to bubble up to the UI
data class DataOrException(val data: T?, val exception: E?) typealias StockPriceOrException = DataOrException Now With this, now you can do: LiveData => LiveData What about errors?
@CodingDoug LiveData // deltas not gonna work, sorry! LiveData> LiveData> // this is what we need: whole-resource updates One problem: LiveData doesn’t support deltas
@CodingDoug LiveData // deltas not gonna work, sorry! LiveData> LiveData> // this is what we need: whole-resource updates LiveData, Exception>> // gotta handle errors One problem: LiveData doesn’t support deltas
@CodingDoug LiveData // deltas not gonna work, sorry! LiveData> LiveData> // this is what we need: whole-resource updates LiveData, Exception>> // gotta handle errors // better this, for flexibility and interop with Jetpack and RecyclerView LiveData>, Exception>> interface QueryItem { val item: T val id: String } One problem: LiveData doesn’t support deltas
@CodingDoug // Generic for all types of Firestore and Realtime Database queries typealias QueryResultsOrException = DataOrException>, E> Let’s escape generics hell! All hail Kotlin.
@CodingDoug // Generic for all types of Firestore and Realtime Database queries typealias QueryResultsOrException = DataOrException>, E> // Specific for stock price query results - not so bad after all? typealias StockPriceQueryResults = QueryResultsOrException Let’s escape generics hell! All hail Kotlin.
@CodingDoug // Generic for all types of Firestore and Realtime Database queries typealias QueryResultsOrException = DataOrException>, E> // Specific for stock price query results - not so bad after all? typealias StockPriceQueryResults = QueryResultsOrException // This sort of thing is what our views want to consume! // Our repository should deal out instances of this: LiveData Let’s escape generics hell! All hail Kotlin.
@CodingDoug ● android.support.v7.recyclerview.extensions.ListAdapter ● It’s like magic! ○ You call: submitList(yourListWithData) ○ It figures out exactly what changed, calls granular Adapter notifications: notifyItemChanged(int) notifyItemInserted(int) notifyItemRemoved(int) etc. ○ What’s the catch? Subclass one abstract base class… RecyclerView extension ListAdapter to the rescue!
@CodingDoug DiffUtil.ItemCallback (from support lib source) public abstract static class ItemCallback { // Return true if oldItem and newItem are the same item in the list public abstract boolean areItemsTheSame(T oldItem, T newItem); }
@CodingDoug DiffUtil.ItemCallback (from support lib source) public abstract static class ItemCallback { // Return true if oldItem and newItem are the same item in the list public abstract boolean areItemsTheSame(T oldItem, T newItem); // Return true if oldItem and newItem contain the same data public abstract boolean areContentsTheSame(T oldItem, T newItem); }
@CodingDoug DiffUtil.ItemCallback (from support lib source) public abstract static class ItemCallback { // Return true if oldItem and newItem are the same item in the list public abstract boolean areItemsTheSame(T oldItem, T newItem); // Return true if oldItem and newItem contain the same data public abstract boolean areContentsTheSame(T oldItem, T newItem); // Return which individual properties changed in the item (optional) public Object getChangePayload(T oldItem, T newItem) { return null; } }
@CodingDoug Implement DiffUtil.ItemCallback for QueryItem // If your query yields List objects... interface QueryItem { val item: T // your actual data item (e.g. StockPrice) val id: String // the database record id }
@CodingDoug Implement DiffUtil.ItemCallback for QueryItem // If your query yields List objects… interface QueryItem { val item: T // your actual data item (e.g. StockPrice) val id: String // the database record id } // ... use this ItemCallback for all QueryItem T types that are Kotlin case classes! open class QueryItemDiffCallback : DiffUtil.ItemCallback>() { override fun areItemsTheSame(oldItem: QueryItem, newItem: QueryItem): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: QueryItem, newItem: QueryItem): Boolean { return oldItem.item == newItem.item } } Magic Kotlin equals() checks all data class properties
@CodingDoug ● Fun to watch, possibly bad to interact with ● Frustrating if touchable items move around on screen! ● Probably not so bad for append-only query results ● Consider using a “refresh” indicator when new data is available Do you really need “live” query results?
@CodingDoug ● Want to browse stock prices after market close on the subway ride home. ● Realtime Database and Firestore have offline caching built in. ● How do you sync data when the app isn’t running? ● Just need to schedule a data sync to device! What about background sync for offline use? Offline data is cool.
@CodingDoug WorkManager Scheduled work Indicate time of execution (and period of execution) Constraints Indicate necessary conditions to operate (network, battery) Work chaining For complex tasks involving serial and/or parallel work
@CodingDoug Schedule periodic work // These APIs are alpha, may change over time val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .setRequiresBatteryNotLow(true) .build()
@CodingDoug Schedule periodic work // These APIs are alpha, may change over time val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .setRequiresBatteryNotLow(true) .build() val request = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) .setConstraints(constraints) .build()
@CodingDoug Schedule periodic work // These APIs are alpha, may change over time val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .setRequiresBatteryNotLow(true) .build() val request = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) .setConstraints(constraints) .build() WorkManager.getInstance() .enqueueUniquePeriodicWork( "syncStocks", ExistingPeriodicWorkPolicy.KEEP, request)
@CodingDoug ● Periodic work doesn’t have exact schedules like cron ○ Over-zealous sync period? ○ Could replace periodic with delayed work, but must reschedule after each run ● Client must know when markets close ● Individual stocks still may have some after-hours trading Periodic work is not the best option for stock sync (IMO)
@CodingDoug ● Server knows when trading stops ● App doesn’t schedule anything at all ● Use Firebase Cloud Messaging to notify the app ● On notification, app schedules a sync with WorkManager ASAP Server push is much better!
@CodingDoug Schedule one-time sync work with WorkManager private fun syncTicker(ticker: String) { // Same constraints as before val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .setRequiresBatteryNotLow(true) .build() }
@CodingDoug Schedule one-time sync work with WorkManager private fun syncTicker(ticker: String) { // Same constraints as before val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .setRequiresBatteryNotLow(true) .build() val data: Data = mapOf("ticker" to ticker).toWorkData() }
@CodingDoug Schedule one-time sync work with WorkManager private fun syncTicker(ticker: String) { // Same constraints as before val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .setRequiresBatteryNotLow(true) .build() val data: Data = mapOf("ticker" to ticker).toWorkData() val workRequest = OneTimeWorkRequestBuilder() .setConstraints(constraints) .setInputData(data) .build() }
@CodingDoug Schedule one-time sync work with WorkManager private fun syncTicker(ticker: String) { // Same constraints as before val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .setRequiresBatteryNotLow(true) .build() val data: Data = mapOf("ticker" to ticker).toWorkData() val workRequest = OneTimeWorkRequestBuilder() .setConstraints(constraints) .setInputData(data) .build() WorkManager.getInstance() .beginUniqueWork("sync_$ticker", ExistingWorkPolicy.REPLACE, workRequest) .enqueue() }
@CodingDoug Implement worker class SingleStockPriceSyncWorker : Worker() { override fun doWork(): Result { val ticker = inputData.getString("ticker", null) // Do your work here synchronously return Result.SUCCESS // or RETRY, or FAILURE } }
@CodingDoug ● https://github.com/CodingDoug/firebase-jetpack ● Implementations for both Realtime Database and Firestore ● Sync individual documents/nodes, and queries ● Jetpack Paging (infinite scroll without infinite query) ● Backend implemented with TypeScript for node and Cloud Functions ● Goal: derive a library of patterns to add to FirebaseUI ● Stay tuned for more! Lots more in the repo