Android App From Scratch Part 3 — Implementing App Logic
In this tutorial series, I will try to create an RSS Reader app step by step. Through this series I will explain:
- How to use Model-View-Presenter in an Android App
- Implementing must have libraries
- Implementing App Logic
- Creating unit tests with JUnit
- Creating Android Instrumentations tests with Espresso
- Continuous Integration with Travis-CI
In this part I will convert our dummy app to a real app. Parsing RSS urls and showing them on a pretty screen. And browsing RSS items with Chrome Custom Tabs.
I created a JSON asset file that includes some popular sites RSS urls. I keep in in assets folder.
[
{
"i": 10001,
"n": "Mashable",
"l": "http://feeds.mashable.com/Mashable"
},
{
"i": 1002,
"n": "Engadget",
"l": "https://www.engadget.com/rss.xml"
},
{
"i": 1003,
"n": "Wired",
"l": "https://www.wired.com/feed/"
},
{
"i": 1004,
"n": "Thenextweb",
"l": "http://feeds2.feedburner.com/thenextweb"
}
]
I parse this JSON file with GSON library as array of Feed
object withFeedParser
class. Each Feed
object is passed to fragments of ViewPager.
I change MainContract
to a real interface for my app.
interface MainContract {
// User actions. Presenter will implement
interface Presenter : BaseMvpPresenter<View> {
fun loadRssFragments()
}
// Action callbacks. Activity/Fragment will implement
interface View : BaseView {
fun onLoadRssFragments(feeds: List<Feed>)
}
}
And MainPresenter
only calls onLoadRssFragments()
method.
class MainPresenter @Inject constructor(private val repository: MainRepository) :
BasePresenter<MainContract.View>(), MainContract.Presenter {
override fun loadRssFragments() {
view?.onLoadRssFragments(repository.parseFeeds())
}
}
Repositories are data local/remote providers. They are abstracted with interfaces. Using interfaces will make unit tests easier.
Implementation doesn’t matter. I care for the behaviour
class MainRepositoryImpl(private val context: Context) : MainRepository {
override fun parseFeeds(): List<Feed> {
val jsonString = Utils.readFromAssets(context, MainRepositoryImpl.RSS_FILE)
val gson = Gson()
val feeds = gson.fromJson(jsonString, Array<Feed>::class.java)
return Arrays.asList(*feeds)
}
companion object {
private const val RSS_FILE = "rss.json"
}
}
interface MainRepository {
fun parseFeeds(): List<Feed>
}
MainActivity then fill ViewPager items with Feed
objects.
override fun onLoadRssFragments(feeds: List<Feed>) {
val fragmentList = ArrayList<RssFragment>()
val titles = ArrayList<String>()
for (feed in feeds) {
fragmentList.add(RssFragment.newInstance(feed))
titles.add(feed.title)
}
val adapter = RssFragmentAdapter(supportFragmentManager, fragmentList, titles)
viewPager.adapter = adapter
}
RssFragment
is a fragment shows a list of RSS items. We can see all actions and callbacks from RssContract.
interface RssContract {
// User actions. Presenter will implement
interface Presenter : BaseMvpPresenter<View> {
fun loadRssItems(feed: Feed, fromCache: Boolean)
fun browseRssUrl(rssItem: RssItem)
}
// Action callbacks. Activity/Fragment will implement
interface View : BaseView, AsyncCallbackView {
fun onRssItemsLoaded(rssItems: List<RssItem>)
fun onBrowse(rssItem: RssItem)
}
}
Here is the implementation of RssFragment.
class RssFragment : BaseFragment(), RssContract.View, SwipeRefreshLayout.OnRefreshListener, RssItemsAdapter.OnItemClickListener {
@Inject
lateinit var presenter: RssContract.Presenter
private lateinit var feed: Feed
private lateinit var adapter: RssItemsAdapter
private var listener: OnItemSelectListener? = null
override val layoutResource = R.layout.fragment_rss
override fun inject(component: ActivityComponent) {
component.inject(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
feed = arguments!!.getSerializable(KEY_FEED) as Feed
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
presenter.attach(this)
adapter = RssItemsAdapter(activity!!, this)
recyclerView.layoutManager = LinearLayoutManager(activity)
recyclerView.adapter = adapter
swRefresh.setOnRefreshListener(this)
presenter.loadRssItems(feed, true)
}
override fun onRssItemsLoaded(rssItems: List<RssItem>) {
adapter.setItems(rssItems)
tvNoItems.visibility = View.GONE
recyclerView.visibility = View.VISIBLE
}
override fun onBrowse(rssItem: RssItem) {
listener?.onItemSelected(rssItem)
}
override fun onItemSelected(rssItem: RssItem) {
presenter.browseRssUrl(rssItem)
}
override fun onRefresh() {
presenter.loadRssItems(feed, false)
}
override fun showLoading() {
if (isAdded) swRefresh.isRefreshing = true
}
override fun hideLoading() {
if (isAdded) swRefresh.isRefreshing = false
}
override fun onFail(error: Error) {
tvNoItems.visibility = View.VISIBLE
recyclerView.visibility = View.GONE
}
override fun onAttach(context: Context?) {
super.onAttach(context)
if (context is OnItemSelectListener) {
listener = context
}
}
override fun onDetach() {
super.onDetach()
listener = null
}
companion object {
private const val KEY_FEED = "key_feed"
fun newInstance(feed: Feed): RssFragment {
val rssFragment = RssFragment()
val bundle = Bundle()
bundle.putSerializable(KEY_FEED, feed)
rssFragment.arguments = bundle
return rssFragment
}
}
interface OnItemSelectListener {
fun onItemSelected(rssItem: RssItem)
}
}
newInstance(Feed feed)
Static method to create a Fragment instance. aFeed
object is given and stored in fragment.init(@Nullable Bundle state)
This method is called inonCreateView()
method. RecyclerView adapter creation and binding to RecyclerView is done here. Initial presenter methodloadRssItems()
is called here.inject()
DI is done here. Each fragment extends fromBaseFragment
should implement this method and inject.showLoading()/hideLoading()
methods to show/hide loading indicator, triggers from Presenter.onRssItemsLoaded(List<RssItem> rssItems)
Presenter fetches and parses the RSS then calls this method with parsedRssItem
objects. Adapter will be filled with this objects.onAttach()/onDetach()
OnItemSelectListener
is binded/unbinded here, a listener for item selection for RecyclerView.onItemSelected(RssItem rssItem)
is triggered when RecyclerView item is selected.
RssPresenter.kt
RssPresenter has the responsibility for all interactions on RssFragment
After fetching and parsing RSS url, all items are cached in SessionData
singleton object. In callbacks for async operations, view should be checked if it is attached to the presenter or not withisAttached()
method. All UI changes (populating list, showing a fail error, hide/show indicators etc. ) is triggered by the presenter.
class RssPresenter @Inject
constructor(private val repository: RssRepository,
private val cache: RssCache) :
BasePresenter<RssContract.View>(), RssContract.Presenter, RssResponseListener {
override fun loadRssItems(feed: Feed, fromCache: Boolean) {
if (cache.hasUrl(feed.url) && fromCache) {
view?.onRssItemsLoaded(cache.getContent(feed.url))
} else {
view?.showLoading()
repository.fetchRss(feed.url, this)
}
}
override fun browseRssUrl(rssItem: RssItem) {
view?.onBrowse(rssItem)
}
override fun getResponse(url: String, response: RssResponse) {
response.success?.apply {
if (isNotEmpty()) {
cache.addContent(url, this)
view?.onRssItemsLoaded(this)
}
}
response.error?.apply {
view?.onFail(this)
}
view?.hideLoading()
}
}
I keep all the logic in the Presenter and Repositories are responsible for the data layer. Presenters don’t matter how data is provided. Here is my RssRepository
:
class RssRepositoryImpl @Inject constructor(private val service: RssService) : RssRepository {
override fun fetchRss(url: String, listener: RssResponseListener) {
service.getRss(url).enqueue(object : ApiCallback<RssFeed>() {
override fun onSuccess(response: RssFeed) {
listener.getResponse(url, RssResponse.success(response.items))
}
override fun onFail(error: Error) {
listener.getResponse(url, RssResponse.error(error))
}
})
}
}
interface RssRepository {
fun fetchRss(url: String, listener: RssResponseListener)
}
RssResponse
is a simple data class wraps success and error objects:
data class RssResponse(val success: List<RssItem>? = null,
val error: Error? = null) {
companion object {
fun success(data: List<RssItem>?): RssResponse = RssResponse(data, null)
fun error(error: Error) = RssResponse(null, error)
}
}
As you can see RssService
is a Retrofit service and injected into repository. I have a NetworkModule
for injection network related objects:
@Module
class NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient() = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY })
.build()
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient) = Retrofit.Builder()
.baseUrl("http://example.com")
.addConverterFactory(RssConverterFactory.create())
.client(client)
.build()
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit) = retrofit.create(RssService::class.java)
}
Presenters and Repositories are feature related objects. So I separated them into another module ActivityModule
:
@Module
class ActivityModule {
@Provides
@ActivityScope
fun provideMainRepository(app: Application): MainRepository = MainRepositoryImpl(app)
@Provides
@ActivityScope
fun provideMainPresenter(repository: MainRepository): MainContract.Presenter = MainPresenter(repository)
@Provides
@ActivityScope
fun provideRssRepository(service: RssService): RssRepository = RssRepositoryImpl(service)
@Provides
@ActivityScope
fun provideRssPresenter(repository: RssRepository,
cache: RssCache): RssContract.Presenter = RssPresenter(repository, cache)
}
As I mentioned before all provided methods’ return type should be interfaces.
In order to have a better organised structure, all features should be in a different folder. It will be easy to find what your are searching.
Here is the full source code for this part.
Continue reading next part?
If you liked the article, please 👏👏👏 so more people can see it! Also, you can follow me on Medium