บทเรียนสำหรับการพัฒนาแอพพลิเคชันแพลตฟอร์ม Android ด้วยภาษา Kotlin ทำ E Book โดยใช้ RecyclerView ร่วมกับ Firebase
ตัวอย่างนี้จะเหมือนตัวอย่างของ Java ส่วนของบทความ:
- Android Studio กับการทำระบบ Bookshelf ร่วมกับ Firebase เบื้องต้น ตอนที่ 1
- Android Studio กับการทำระบบ Bookshelf ร่วมกับ Firebase เบื้องต้น ตอนที่ 3
ดังนั้นเราจะเห็นว่าเราจะต้องสร้างตัวอย่างของ Realtime Database บน Firebase มาก่อนโดยมีโครงสร้างเหมือนตัวอย่างของบทความก่อนหน้าก็จะขอละไว้ไม่อธิบายนะครับ:
หน้าแรกเราจะมี Key หลักที่ประกอบด้วย title คือชื่อการ์ตูน, desc คำโปรย, photos คือหน้าปกเป็นลิงค์ url ภาพ และ สุดท้ายคือ pages ที่จะเป็นการบอกว่าในการ์ตูนตอนนั้นมีหน้าย่อยที่เก็บ หน้าต่างๆ กี่หน้า:
โครงสร้าง Firebase หน้าแรกจะเป็น
URL แบบ JSON: https://enet5-7f9f6.firebaseio.com/bookshelf.json
ส่วนหน้าที่มี pages จะมี photo เป็น Path ของรูปภาพ โดยแบ่งตาม Node ที่เป็น Unique ID แต่ละหน้าตามตัวอย่าง:
URL แบบ JSON ไว้ดูเล่น https://enet5-7f9f6.firebaseio.com/bookshelf/data/key00004.json
ตรวจสอบการเปิด Permission ของ Database ให้ดี ไปที่ Firebase ตั้งค่า Rule ดังนี้:
{ "rules": { ".read": true, ".write": true } }
ต่อมาให้ทำการสร้างแอพพลิเคชันขึ้นมาใหม่เป็น Empty Activity ทำการเชื่อมต่อ Firebase โดยไปที่ เมนู: Tools-> Firebase
เปิด res->values->colours.xml ขึ้นมาทำชุดสีใหม่เล็กน้อย (ข้ามได้ถ้าไม่สำคัญ)
<?xml version="1.0" encoding="utf-8"?> <resources> <color name="colorPrimary">#4CAF50</color> <color name="colorPrimaryDark">#4CAF50</color> <color name="colorAccent">#FFC107</color> <color name="colorDarkBG">#333</color> <color name="colorWhite">#FFFFFF</color> </resources>
ทำการดาวน์โหลด RecyclerView มาใช้งานหลังจากนั้นให้นำ Widget ไปวาง ทำการเชื่อม ConstraintLayout ทุกมุม ตั้งชื่อ ID ของ RecyclerView ว่า recyclerView
ไฟล์ activity_main.xml จะเป็นดังนี้:
<?xml version="1.0" encoding="utf-8"?> <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/colorDarkBG" tools:context=".MainActivity"> <android.support.v7.widget.RecyclerView android:layout_width="match_parent" android:layout_height="match_parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="parent" android:id="@+id/recyclerView"/> </android.support.constraint.ConstraintLayout>
ทำการไปที่ MainActivity.kt ประกาศตัวแปรที่ Global Variable ใต้ Class ด้วยคำสั่ง:
lateinit var recyclerView : RecyclerView
ไปที่ onCreate() ทำการ ประกาศตัวแปรรับ findViewById ที่ acitivy_main.xml
recyclerView = findViewById(R.id.recyclerView)
ไปที่ เมนู Tools -> Firebase เพื่อเปิดใช้งาน Firebase Assistance
เปิดเลือก Realtime Database กดที่ Save and retrieve data
เลือก Choose an Existing Firebase or Google Project ของเราที่เคยสร้างไว้
ทำตามขั้นตอนที่ 1 และ ขั้นตอนที่ 2 โดยมันจะมีการบังคับให้เราเข้าระบบ Google บัญชีที่เราใช้ในการทำ Firebase อยู่อีกทั้งขั้นตอนนี้จะมีการ Sync ตัว Gradle ให้ด้วยเรียบร้อย:
เมื่อเสร็จแล้วซ่อนหน้าต่าง Assistance นี้ออกไป แก้ไข Module App ของ Gradle ใหม่เล็กน้อย:
implementation 'com.google.firebase:firebase-database:16.0.1' implementation 'com.google.firebase:firebase-core:16.0.1' implementation 'com.squareup.picasso:picasso:2.5.2'
แก้ไขส่วนของ Project Gradle เล็กน้อย แก้ไข Class Path dependencie ดังนี้:
แก้ google-services ที่เป็น 4.1.0 ให้อยู่ใน เวอร์ชันที่เสถียรคือ 4.0.0
classpath 'com.google.gms:google-services:4.1.0'
แก้ไขเป็น
classpath 'com.google.gms:google-services:4.0.0'
คลิกขวาที่ Package ของเราเลือก New -> Kotlin File/Class
ตั้งประเภทเป็น Class ตั้งชื่อว่า DataModel, DataAdapter ตรวจสอบตำแหน่ง Class
ไปที่ DataModel.kt ประกาศตัวแปรใน Class ของ DataModel ดังนี้ (สังเกตุมันคือ Key ของ Firebase ของเรา)
class DataModel { var title: String?=null var desc: String?=null var photos: String?=null var files: String?=null var key:String?=null var photo:String?=null }
เราจะทำ Contructor Class ของตัวแปรทั้งหมดใน Class DataModel จะเป็นดังนี้:
package com.daydev.iapplication class DataModel { var title: String?=null var desc: String?=null var photos: String?=null var files: String?=null var key:String?=null var photo:String?=null constructor() constructor(title: String?, desc: String?, photos: String?, key: String?, files: String?, photo: String?) { this.title = title this.desc = desc this.photos = photos this.key = key this.files = files this.photo = photo } fun toMap(): Map<String, Any> { val result = HashMap<String, Any>() result.put("title", title!!) result.put("desc", desc!!) result.put("thumbnail", photos!!) result.put("key", key!!) result.put("files", files!!) result.put("photo", photo!!) return result } }
มีการเรียกใช้ Map และ HashMap ในการทำ Dictionary ระหว่าง Key, Value จาก Firebase มาเก็บใน Model ของเรา และสร้าง None Argument Constructor ที่
constructor()
Constructor คือ สิ่งที่มีไว้สำหรับกำหนดค่าเริ่มต้นให้กับตัวแปร Class ของ object Constructor จะทำงานอัตโนมัติเมื่อมีการ new object ขึ้นมา
ต่อมาเราจะสร้าง res->layout ขึ้นมาใหม่ชื่อว่า model.xml
ออกแบบ model.xml ด้วย text mode ดังนี้:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="300dp" android:orientation="horizontal"> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <ImageView android:id="@+id/thumbnail" android:layout_width="match_parent" android:layout_height="240dp" android:scaleType="centerCrop"/> <TextView android:id="@+id/title" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_weight="1" android:textAlignment="center" android:textColor="@color/colorWhite" android:textSize="16sp"/> </LinearLayout> </LinearLayout>
ปที่ DataAdapter เราจะประกาศฟังก์ชัน ในรอบนี้จะทำงานเรียก DataModel มาปรากฏ และใช้งาน ViewHolder มาจัดการเช่นเดิม และรอบนี้เราจะเรียก List ของ Array ทั้งก้อนมาใส่ โดยใช้ Pattern จาก Class DataModel
ให้ทำการ alt + enter ที่ class ก่อนเพื่อ Implement Memebers ของ Class นี้ให้มีเมธอดหลักของการจัดการ DataAdapter:
เราจะได้ DataAdapter ดังนี้ โดยมีการเรียก ViewHolder เป็น inner Class (inner Function) ส่วนของ ViewHolder นั้นมีการประกาศ widget ของ ImageView และ TextView แล้วผ่านตัวแปร thumbnail, textTitle:
class DataAdapter(val dataModelList: List<DataModel>) : RecyclerView.Adapter<ViewHolder>() { override fun onCreateViewHolder(p0: ViewGroup, p1: Int): ViewHolder { } override fun onBindViewHolder(p0: ViewHolder, p1: Int) { } override fun getItemCount(): Int { } } class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { var textTitle: TextView = itemView.findViewById(R.id.title) var imageView: ImageView init { imageView = itemView.findViewById(R.id.thumbnail) } }
ในเมธอด getItemCount() ให้ใส่ return dataModelList.size เพื่อรับจำนวน Arrays
override fun getItemCount(): Int { return dataModelList.size }
ไปที่ เมธอด onCreateViewHolder() ให้ลบข้อมูล แล้วใส่ Code ต่อไปนี้ เพื่อทำการสร้าง ViewHolder รายการแต่ละแถวผ่านการแสดงผลด้วย ไฟล์ layout ชื่อ model.xml
override fun onCreateViewHolder(p0: ViewGroup, p1: Int): ViewHolder { return ViewHolder(LayoutInflater.from(p0.context).inflate(R.layout.model,p0,false)) }
ในเมธอด onBindViewHolder() ให้เราสร้างการ เรียกใช้ DataModel ไปยัดข้อมูลง ViewHolder ทีละแถว ส่วนของรูปภาพเราจัดการ Cache ด้วย Picasso (3rd Party การจัดการ แคช ของรูปภาพที่มาจาก Web Service เพื่อลดการโหลดกระตุกของแอพฯ สำหรับการจัดการแคชบนรูปภาพ ImageView Cache มีการดาวน์โหลดรูปภาพภายหลัง Activity เริ่มทำงานโดยเก็บ cache ให้ทันที สามารถใช้งานได้ง่ายทั้งแบบ jar ไว้ใน Project หรือ Build ผ่าน Gradle ตรงๆ ข้อมูลเพิ่มเติม:http://square.github.io/picasso/ )
override fun onBindViewHolder(p0: ViewHolder, p1: Int) { val dataModel = dataModelList[p1] p0.textTitle.text = dataModel.title Picasso.with(p0.itemView.context).load(dataModel.photos) .error(R.mipmap.ic_launcher) .placeholder(R.mipmap.ic_launcher) .into(p0.imageView) }
กลับไปที่ MainActivity.kt เพิ่ม Global var ดังนี้:
private val TAG = "Comic" private lateinit var response_data: MutableList<DataModel> private var dataAdapter: DataAdapter? = null private lateinit var recyclerView: RecyclerView private lateinit var firebaseDatabase: FirebaseDatabase private lateinit var databaseReference: DatabaseReference
ทำการสร้าง LayoutGridManager เป็น 2 ประกาศตัวแปร response_data เป็น mutableList() เป็น ListArray รูปแบบหนึ่ง และองค์ประกอบอื่นๆ ใน onCreate()
recyclerView = findViewById(R.id.recyclerView) recyclerView!!.layoutManager = LinearLayoutManager(this) recyclerView!!.setLayoutManager(GridLayoutManager(this, 2)) firebaseDatabase = FirebaseDatabase.getInstance() databaseReference = firebaseDatabase!!.getReference("bookshelf/data") response_data = mutableListOf() dataAdapter = DataAdapter(response_data as ArrayList<DataModel>) recyclerView!!.setAdapter(dataAdapter) bindingData()
ทำการ Create method ใหม่ โดยการกด Alt + Enter ที่ bindingData()
จะเห็นว่ามีการเรียกไปยัง refernce Database ส่วนของ Node ที่ชื่อว่า bookshelf/data ให้ตรงกับส่วนที่เราเก็บข้อมูลไว้ใน Firebase RealtimeDatabase เมื่อเสร็จขั้นตอนการ setAdapter ของ recyclersView แล้วเราจะต้องสร้าง Method ใหม่ชื่อว่า bindingData();
private fun bindingData() { databaseReference!!.addChildEventListener(object : ChildEventListener { override fun onCancelled(p0: DatabaseError) { } override fun onChildMoved(p0: DataSnapshot, p1: String?) { } override fun onChildChanged(p0: DataSnapshot, p1: String?) { } override fun onChildAdded(p0: DataSnapshot, p1: String?) { response_data!!.add(p0.getValue(DataModel::class.java)!!) dataAdapter!!.notifyDataSetChanged() } override fun onChildRemoved(p0: DataSnapshot) { } }) }
ทดสอบการทำงานโดย run ตัว Android Studio ขึ้นมาถ้าไม่มีข้อผิดพลาดจะเป็นดังนี้:
ต่อมาเราจะทำหน้า เนื้อหาข้างใน ไปคลิกขวาที่ apps ให้ทำการเพิ่ม Empty Activity ใหม่ขึ้นมาว่า InformationActivity โดยเราจะแก้ไข activity_information.xml เป็นดังนี้:
<?xml version="1.0" encoding="utf-8"?> <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".InformationActivity"> <android.support.v7.widget.RecyclerView android:id="@+id/recycleComic" android:layout_width="0dp" android:layout_height="0dp" android:layout_marginBottom="1dp" android:layout_marginEnd="1dp" android:layout_marginLeft="1dp" android:layout_marginRight="1dp" android:layout_marginStart="1dp" android:layout_marginTop="1dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </android.support.constraint.ConstraintLayout>
ทำการประกาศตัวแปรต่อไปนี้ ใน InformationActivity.kt
private val TAG = "Comic" private val response_data: List<DataModel>? = null private val dataAdapter: ComicDataAdapter? = null private val mRecyclerView: RecyclerView? = null private val get_key: String? = null
ระบบจะทำการบังคับให้เราสร้าง Class ใหม่ชื่อ ComicDataAdapter ขึ้นมา(กด Alt+Enter) ทำตาม Step ภาพด้านล่าง:
Class ชื่อ ComicDataAdapter นั้นให้แก้ไขไฟล์ดังนี้:
class ComicDataAdapter (val dataModelList: List<DataModel>) : RecyclerView.Adapter<DataViewHolder>(){ override fun onCreateViewHolder(p0: ViewGroup, p1: Int): DataViewHolder { return DataViewHolder(LayoutInflater.from(p0.context).inflate(R.layout.pages,p0,false)) } override fun onBindViewHolder(p0: DataViewHolder, p1: Int) { val dataModel = dataModelList[p1] Picasso.with(p0.itemView.context).load(dataModel.photo) .error(R.mipmap.ic_launcher) .placeholder(R.mipmap.ic_launcher) .into(p0.imageView) } override fun getItemCount(): Int { return dataModelList.size } } class DataViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { var imageView: ImageView init { imageView = itemView.findViewById(R.id.imageView) } }
มีการทำงานคล้ายกับ DataAdapter แค่ส่วนของ ViewHolder นั้นเราจะแยกใช้อีกตัวคนละชื่อ ดังนั้นใน ComicDataAdapter จะเรียก inner Class ส่วนนี้ว่า DataViewHolder
สร้าง Layout ใหม่ชื่อ pages.xml เป็นหน้าอ่านหนังสือ:
<?xml version="1.0" encoding="utf-8"?> <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:layout_width="0dp" android:layout_height="0dp" tools:srcCompat="@tools:sample/avatars[12]" android:id="@+id/imageView" android:layout_marginTop="8dp" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="8dp" app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="8dp" android:layout_marginBottom="8dp" app:layout_constraintBottom_toBottomOf="parent"/> </android.support.constraint.ConstraintLayout>
เปิดไฟล์ DataAdapter.kt ให้เพิ่ม Global Var เข้าไปคือ:
private val TAG = "Comic" val mylist = ArrayList<String>()
และทำการแก้ไขส่วนของ onBindViewHolder() ใน DataAdapter.kt ดังนี้:
var databaseReference = FirebaseDatabase.getInstance().reference databaseReference.child("bookshelf/data").addValueEventListener(object : ValueEventListener { override fun onDataChange(dataSnapshot: DataSnapshot) { Log.d(TAG, "Count= " + dataSnapshot.childrenCount) for (childDataSnapshot in dataSnapshot.children) { Log.d(TAG, "snapshot= " + childDataSnapshot.key!!) mylist.add(childDataSnapshot.key!!) } } override fun onCancelled(databaseError: DatabaseError) { } })
เป็นการดึง DataSnapshot ของ Firebase ทั้งหมดมาวน Foreach เพื่อเก็บค่า Key ของข้อมูลเราสำหรับนำไปอ้างเพื่อส่ง Intent โดยตัวอย่างของผมคือการเก็บลง ArrayList ชื่อ mylist
เพิ่มคำสั่ง setOnClickListener เข้าไปให้แตะที่ ViewHolder.itemView.thumbnail แล้วส่ง Intent ค่า Key ไปหน้า InformationActivity:
p0.imageView.setOnClickListener(View.OnClickListener { v -> val filePath = dataModel.files Log.d(TAG, "filePath=$filePath") val readActivity = Intent(v.context, InformationActivity::class.java) readActivity.putExtra("filePath", filePath) readActivity.putExtra("keys", mylist[p1]) v.context.startActivity(readActivity) })
ดังนั้นไฟล์ DataAdapter.kt จะเป็นดังนี้:
package com.daydev.iapplication import android.content.Context import android.support.v7.widget.RecyclerView import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView import com.squareup.picasso.Picasso import com.google.firebase.database.DatabaseError import com.google.firebase.database.DataSnapshot import com.google.firebase.database.ValueEventListener import com.google.firebase.database.FirebaseDatabase import android.content.Intent class DataAdapter(val dataModelList: List<DataModel>) : RecyclerView.Adapter<ViewHolder>() { private val TAG = "Comic" val mylist = ArrayList<String>() override fun onCreateViewHolder(p0: ViewGroup, p1: Int): ViewHolder { return ViewHolder(LayoutInflater.from(p0.context).inflate(R.layout.model,p0,false)) } override fun onBindViewHolder(p0: ViewHolder, p1: Int) { val dataModel = dataModelList[p1] p0.textTitle.text = dataModel.title var databaseReference = FirebaseDatabase.getInstance().reference databaseReference.child("bookshelf/data").addValueEventListener(object : ValueEventListener { override fun onDataChange(dataSnapshot: DataSnapshot) { Log.d(TAG, "Count= " + dataSnapshot.childrenCount) for (childDataSnapshot in dataSnapshot.children) { Log.d(TAG, "snapshot= " + childDataSnapshot.key!!) mylist.add(childDataSnapshot.key!!) } } override fun onCancelled(databaseError: DatabaseError) { } }) Picasso.with(p0.itemView.context).load(dataModel.photos) .error(R.mipmap.ic_launcher) .placeholder(R.mipmap.ic_launcher) .into(p0.imageView) p0.imageView.setOnClickListener(View.OnClickListener { v -> val filePath = dataModel.files Log.d(TAG, "filePath=$filePath") val readActivity = Intent(v.context, InformationActivity::class.java) readActivity.putExtra("filePath", filePath) readActivity.putExtra("keys", mylist[p1]) v.context.startActivity(readActivity) }) } override fun getItemCount(): Int { return dataModelList.size } } class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { var textTitle: TextView = itemView.findViewById(R.id.title) var imageView: ImageView init { imageView = itemView.findViewById(R.id.thumbnail) } }
กลับไปยัง InformationActivity.kt ให้เพิ่มคำสั่งต่อไปนี้ใน onCreate()
val intent = intent get_key = intent.getStringExtra("keys") firebaseDatabase = FirebaseDatabase.getInstance() databaseReference = firebaseDatabase!!.getReference("bookshelf/data/$get_key/pages") Log.d(TAG, "bookshelf/data/$get_key/pages") response_data = ArrayList() val layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) mRecyclerView = findViewById(R.id.recycleComic) as RecyclerView mRecyclerView!!.layoutManager = layoutManager dataAdapter = ComicDataAdapter(response_data) mRecyclerView!!.adapter = dataAdapter comicBindingData()
ทำการสร้าง Method ใหม่ของ comicBindingData()
แก้ไขฟังก์ชัน comicBindingData() ดังนี้: (เหมือน bindingData() ใน MainActivity)
private fun comicBindingData() { databaseReference!!.addChildEventListener(object : ChildEventListener { override fun onCancelled(p0: DatabaseError) { } override fun onChildMoved(p0: DataSnapshot, p1: String?) { } override fun onChildChanged(p0: DataSnapshot, p1: String?) { } override fun onChildAdded(p0: DataSnapshot, p1: String?) { response_data!!.add(p0.getValue(DataModel::class.java)!!) dataAdapter!!.notifyDataSetChanged() } override fun onChildRemoved(p0: DataSnapshot) { } }) }
ทดสอบแอพพลิเคชันของเรา โดยการแตะเปลี่ยนหน้า:
กลายเป็นว่า Kotlin Android นั้นสามารถทำงานร่วมกับ Firebase ได้สบายๆ แค่นี้เราก็ได้แอพพลิเคชัน Ebook ที่มีระบบหลังบ้านเป็น Firebase ได้ง่ายแล้วครับ
ไม่ต้องถามหา Source Code นะครับ พยายามทำตาม เพื่อความเข้าใจดีกว่า 🙂