Room
Room은 Android Jetpack의 구성요소 중 하나로, 로컬 Database를 관리해주는 라이브러리이다.
AAC(Android Architecture Components) 구성요소로 Room 라이브러리가 나왔으며, SQLite를 활용했다.
SQLite는 쿼리를 수동으로 업데이트 해야하고 여러모로 귀찮고 복잡하고 어렵고 싫다! 걍 싫다!
이런 복잡한걸 해결하기 위해 Room이 나오게 되었고, Room은 굉장히 편리하게 동작한다.
Room 기본 구성요소
Room에는 3가지 구성요소를 가진다.
- 데이터베이스 클래스(Room Database): 데이터베이스를 보유하고 앱의 영구 데이터와의 기본 연결을 위한 기본 액세스 포인트 역할을 합니다.
- 데이터 항목(Entities): 앱 데이터베이스의 테이블을 나타냅니다.
- 데이터 액세스 객체(DAO): 앱이 데이터베이스의 데이터를 쿼리, 업데이트, 삽입, 삭제하는 데 사용할 수 있는 메서드를 제공합니다.
데이터베이스 클래스는 DB와 연결된 DAO 인스턴스를 앱에 제공한다. 실질적으로 DB를 생성하고 DAO를 연결시켜주는 구성요소이다.
데이터 항목, 즉 Entities는 우리가 아는 그 엔티티를 생성해주는 클래스이다.
데이터 액세스 객체, DAO는 DB의 쿼리를 작성하는 클래스라고 생각하면 편하다.
SQLite
그 전에, 먼저 SQLite를 사용하면 어떻게 쓰이는지 보도록 하자.
예전에 프로젝트를 진행하면서 사용했던 SQLite 코드를 가져왔다.
먼저 SQLite를 설정하고 관리하는 DBHelper 클래스이다.
DBHelper
public class DBHelper extends SQLiteOpenHelper {
public static final int DATABASE_VERSION = 1; // 데이터베이스 스키마를 변경하는 경우 데이터베이스 버전을 증가시켜야 합니다.
public static final String DATABASE_NAME = "CharacterList.db"; // 데이터베이스 이름
public DBHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase sqLiteDatabase) {
sqLiteDatabase.execSQL(FeedEntry.SQL_CREATE_ENTRIES); //테이블 생성
}
@Override
public void onUpgrade(SQLiteDatabase sqLiteDatabase, int i, int i1) {
sqLiteDatabase.execSQL(FeedEntry.SQL_DELETE_ENTRIES);
onCreate(sqLiteDatabase);
}
public static class FeedEntry implements BaseColumns {
public static final String TABLE_NAME = "character"; //테이블 명
public static final String COLUMN_NAME_IMAGE = "image"; //컬럼 명
public static final String COLUMN_NAME_NAME = "name"; //컬럼 명
public static final String COLUMN_NAME_CHARACTER_LEVEL = "character_level"; //컬럼 명
public static final String COLUMN_NAME_CLASS = "class"; //컬럼 명
public static final String COLUMN_NAME_ITEM_LEVEL = "item_level"; //컬럼 명
public static final String COLUMN_NAME_SERVER = "server"; //컬럼 명
//테이블 생성 쿼리
private static final String SQL_CREATE_ENTRIES =
"CREATE TABLE " + FeedEntry.TABLE_NAME + " (" +
FeedEntry.COLUMN_NAME_IMAGE + " TEXT," +
FeedEntry.COLUMN_NAME_NAME + " TEXT," +
FeedEntry.COLUMN_NAME_CHARACTER_LEVEL + " TEXT," +
FeedEntry.COLUMN_NAME_CLASS + " TEXT," +
FeedEntry.COLUMN_NAME_ITEM_LEVEL + " TEXT," +
FeedEntry.COLUMN_NAME_SERVER + " TEXT)";
//DROP TABLE 테이블을 삭제 쿼리
//IF EXISTS 절을 사용하면 삭제하려는 데이터베이스나 테이블이 존재하지 않아서 발생하는 에러를 미리 방지.
private static final String SQL_DELETE_ENTRIES =
"DROP TABLE IF EXISTS " + FeedEntry.TABLE_NAME;
}
}
테이블을 생성하고 여러가지 관리를 하는 코드가 있다.
다음은 DB를 사용하는 클래스의 코드를 다 가져왔다.
크롤링을 하는 코드와 RecyclerView에 넣는 코드도 섞여 있지만 SQLite가 얼마나 험악한 친구인지 보여주기 위해 다 보여주겠다.
CharacterFragment
public class CharacterFragment extends Fragment {
Context context;
FloatingActionButton addCharBtn;
RecyclerView listView;
CharacterFragmentListItemAdapter adapter;
CharacterFragmentListItem listItem;
String nickname = "https://lostark.game.onstove.com/Profile/Character/";
private DBHelper mDbHelper;
private SQLiteDatabase db;
ArrayList<CharacterFragmentListItem> mArrayList = new ArrayList<>();
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_character, container, false);
context = view.getContext();
addCharBtn = view.findViewById(R.id.addCharBtn);
listView = view.findViewById(R.id.listView);
LinearLayoutManager linearLayoutManager = new LinearLayoutManager(context);
listView.setLayoutManager(linearLayoutManager);
adapter = new CharacterFragmentListItemAdapter();
//DBHelper 객체를 선언해줍니다.
mDbHelper = new DBHelper(context);
//쓰기모드에서 데이터 저장소를 불러옵니다.
db = mDbHelper.getWritableDatabase();
OnCreateBackgroundTask();
addCharBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
final EditText dlgEdt = new EditText(getActivity());
nickname = "https://lostark.game.onstove.com/Profile/Character/";
AlertDialog.Builder dlg = new AlertDialog.Builder(getActivity());
dlg.setTitle("캐릭터 검색");
dlg.setView(dlgEdt);
dlg.setPositiveButton("검색", new DialogInterface.OnClickListener() {
@SuppressLint("NotifyDataSetChanged")
@Override
public void onClick(DialogInterface dialogInterface, int i) {
if (dlgEdt.getText().toString().equals("")) {
Toast.makeText(context, "닉네임을 다시 입력해주세요." , Toast.LENGTH_SHORT).show();
} else {
nickname += dlgEdt.getText().toString();
AddCharacter(nickname);
adapter.notifyDataSetChanged();
Toast.makeText(context, dlgEdt.getText().toString() + "의 정보를 추가하였습니다." , Toast.LENGTH_SHORT).show();
//OnRecreateBackgroundTask();
}
}
});
dlg.show();
}
});
return view;
}
//BackgroundTask
Disposable onCreateBackgroundTask;
void OnCreateBackgroundTask() {
//onPreExecute
onCreateBackgroundTask = Observable.fromCallable(() -> {
//doInBackground
@SuppressLint("Recycle") Cursor c = db.rawQuery ("SELECT * FROM " + DBHelper.FeedEntry.TABLE_NAME, null);
while (c.moveToNext ()) {
@SuppressLint("Range") String img_result = c.getString (c.getColumnIndex (DBHelper.FeedEntry.COLUMN_NAME_IMAGE));
@SuppressLint("Range") String name_result = c.getString (c.getColumnIndex (DBHelper.FeedEntry.COLUMN_NAME_NAME));
@SuppressLint("Range") String charLevel_result = c.getString (c.getColumnIndex (DBHelper.FeedEntry.COLUMN_NAME_CHARACTER_LEVEL));
@SuppressLint("Range") String class_result = c.getString (c.getColumnIndex (DBHelper.FeedEntry.COLUMN_NAME_CLASS));
@SuppressLint("Range") String itemLevel_result = c.getString (c.getColumnIndex (DBHelper.FeedEntry.COLUMN_NAME_ITEM_LEVEL));
@SuppressLint("Range") String server_result = c.getString (c.getColumnIndex (DBHelper.FeedEntry.COLUMN_NAME_SERVER));
listItem = new CharacterFragmentListItem(img_result, name_result, charLevel_result, class_result, itemLevel_result, server_result);
adapter.addItem(listItem);
mArrayList.add(listItem);
Log.d("loggg", img_result + name_result + charLevel_result + class_result + itemLevel_result + server_result);
}
listView.setAdapter(adapter);
return null;
}).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe((result) -> {
//onPostExecute
onCreateBackgroundTask.dispose();
}, throwable -> System.out.println("Error"));
}
//ReCreateBackgroundTask
Disposable onRecreateBackgroundTask;
void OnRecreateBackgroundTask() {
//onPreExecute
//adapter.removeItem();
onRecreateBackgroundTask = Observable.fromCallable(() -> {
//doInBackground
@SuppressLint("Recycle") Cursor c = db.rawQuery ("SELECT * FROM " + DBHelper.FeedEntry.TABLE_NAME, null);
while (c.moveToNext ()) {
@SuppressLint("Range") String img_result = c.getString (c.getColumnIndex (DBHelper.FeedEntry.COLUMN_NAME_IMAGE));
@SuppressLint("Range") String name_result = c.getString (c.getColumnIndex (DBHelper.FeedEntry.COLUMN_NAME_NAME));
@SuppressLint("Range") String charLevel_result = c.getString (c.getColumnIndex (DBHelper.FeedEntry.COLUMN_NAME_CHARACTER_LEVEL));
@SuppressLint("Range") String class_result = c.getString (c.getColumnIndex (DBHelper.FeedEntry.COLUMN_NAME_CLASS));
@SuppressLint("Range") String itemLevel_result = c.getString (c.getColumnIndex (DBHelper.FeedEntry.COLUMN_NAME_ITEM_LEVEL));
@SuppressLint("Range") String server_result = c.getString (c.getColumnIndex (DBHelper.FeedEntry.COLUMN_NAME_SERVER));
listItem = new CharacterFragmentListItem(img_result, name_result, charLevel_result, class_result, itemLevel_result, server_result);
adapter.addItem(listItem);
mArrayList.add(listItem);
Log.d("loggg", img_result + name_result + charLevel_result + class_result + itemLevel_result + server_result);
}
listView.setAdapter(adapter);
return null;
}).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe((result) -> {
//onPostExecute
onRecreateBackgroundTask.dispose();
}, throwable -> System.out.println("Error"));
}
//BackgroundTask
Disposable addCharacter;
void AddCharacter(String URLs) {
//onPreExecute
boolean isConnected = isNetworkConnected(context);
if (!isConnected)
{
androidx.appcompat.app.AlertDialog.Builder builder = new androidx.appcompat.app.AlertDialog.Builder(context);
builder.setTitle("알림")
.setMessage("네트워크 연결 상태를 확인해 주세요.")
.setPositiveButton("확인", null)
.create()
.show();
} else {
addCharacter = Observable.fromCallable(() -> {
//doInBackground
String name_result = "";
String server_result = "";
String charLevel_result = "";
String itemLevel_result = "";
String class_result = "";
String img_result = "";
try {
Document document = Jsoup.connect(URLs).get();
Elements name_elements = document.select("span[class=profile-character-info__name]");
for (Element element : name_elements) {
name_result = name_result + element.text();
}
Elements server_elements = document.select("span[class=profile-character-info__server]");
for (Element element : server_elements) {
server_result = server_result + element.text();
}
Elements charLevel_elements = document.select("span[class=profile-character-info__lv]");
for (Element element : charLevel_elements) {
charLevel_result = charLevel_result + element.text();
}
Elements itemLevel_elements = document.select("div[class=level-info2__expedition]");
for (Element element : itemLevel_elements) {
itemLevel_result = itemLevel_result + element.text();
}
String[] itemLevel_result_split = itemLevel_result.split("벨");
itemLevel_result = itemLevel_result_split[1];
Elements class_elements = document.select("img[class=profile-character-info__img]");
class_result = class_elements.attr("alt");
Elements img_elements = document.select("img[class=profile-character-info__img]");
img_result = img_elements.attr("src");
//데이터를 테이블에 삽입합니다.
insertNumber(img_result, name_result, charLevel_result, class_result, itemLevel_result, server_result);
return null;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe((listItem) -> {
//onPostExecute
addCharacter.dispose();
}, throwable -> System.out.println("Error"));
}
}
//SQLite 데이터 삽입
private void insertNumber(String img_result, String name_result, String charLevel_result, String class_result, String itemLevel_result, String server_result){
ContentValues values = new ContentValues();
values.put(DBHelper.FeedEntry.COLUMN_NAME_IMAGE, img_result);
values.put(DBHelper.FeedEntry.COLUMN_NAME_NAME, name_result);
values.put(DBHelper.FeedEntry.COLUMN_NAME_CHARACTER_LEVEL, charLevel_result);
values.put(DBHelper.FeedEntry.COLUMN_NAME_CLASS, class_result);
values.put(DBHelper.FeedEntry.COLUMN_NAME_ITEM_LEVEL, itemLevel_result);
values.put(DBHelper.FeedEntry.COLUMN_NAME_SERVER, server_result);
db.insert(DBHelper.FeedEntry.TABLE_NAME, null, values);
}
//SQLite 데이터 수정
/* 사용하지 않음
private void updateNumber(String old_img_result, String old_name_result, String old_charLevel_result, String old_class_result, String old_itemLevel_result, String old_server_result, String new_img_result, String new_name_result, String new_charLevel_result, String new_class_result, String new_itemLevel_result, String new_server_result){
//수정된 값들을 values 에 추가한다.
ContentValues values = new ContentValues();
values.put(DBHelper.FeedEntry.COLUMN_NAME_IMAGE, new_img_result);
values.put (DBHelper.FeedEntry.COLUMN_NAME_NAME, new_name_result);
values.put (DBHelper.FeedEntry.COLUMN_NAME_CHARACTER_LEVEL, new_charLevel_result);
values.put (DBHelper.FeedEntry.COLUMN_NAME_CLASS, new_class_result);
values.put (DBHelper.FeedEntry.COLUMN_NAME_ITEM_LEVEL, new_itemLevel_result);
values.put (DBHelper.FeedEntry.COLUMN_NAME_SERVER, new_server_result);
// WHERE 절 수정될 열을 찾는다.
String selection = DBHelper.FeedEntry.COLUMN_NAME_IMAGE + " LIKE ?" +
" AND "+ DBHelper.FeedEntry.COLUMN_NAME_NAME + " LIKE ?" +
" AND "+ DBHelper.FeedEntry.COLUMN_NAME_CHARACTER_LEVEL + " LIKE ?" +
" AND "+ DBHelper.FeedEntry.COLUMN_NAME_CLASS + " LIKE ?" +
" AND "+ DBHelper.FeedEntry.COLUMN_NAME_ITEM_LEVEL + " LIKE ?" +
" AND "+ DBHelper.FeedEntry.COLUMN_NAME_SERVER + " LIKE ?";
String[] selectionArgs = {old_img_result, old_name_result, old_charLevel_result, old_class_result, old_itemLevel_result, old_server_result};
db.update(DBHelper.FeedEntry.TABLE_NAME, values, selection, selectionArgs);
}
*/
//SQLite 데이터 삭제
private void deleteNumber(String img_result, String name_result, String charLevel_result, String class_result, String itemLevel_result, String server_result) {
//WHERE 절 삭제될 열을 찾는다.
String selection = DBHelper.FeedEntry.COLUMN_NAME_IMAGE + " LIKE ?" +
" and " + DBHelper.FeedEntry.COLUMN_NAME_NAME + " LIKE ?" +
" and " + DBHelper.FeedEntry.COLUMN_NAME_CHARACTER_LEVEL + " LIKE ?" +
" and " + DBHelper.FeedEntry.COLUMN_NAME_CLASS + " LIKE ?" +
" and " + DBHelper.FeedEntry.COLUMN_NAME_ITEM_LEVEL + " LIKE ?" +
" and " + DBHelper.FeedEntry.COLUMN_NAME_SERVER + " LIKE ?";
//삭제될 열을 찾을 데이터
String[] selectionArgs = {img_result, name_result, charLevel_result, class_result, itemLevel_result, server_result};
db.delete(DBHelper.FeedEntry.TABLE_NAME, selection, selectionArgs);
}
//인터넷 연결 확인
public boolean isNetworkConnected(Context context)
{
ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
@SuppressLint("MissingPermission") NetworkInfo mobile = manager.getNetworkInfo(ConnectivityManager.TYPE_MOBILE);
@SuppressLint("MissingPermission") NetworkInfo wifi = manager.getNetworkInfo(ConnectivityManager.TYPE_WIFI);
@SuppressLint("MissingPermission") NetworkInfo wimax = manager.getNetworkInfo(ConnectivityManager.TYPE_WIMAX);
boolean bwimax = false;
if (wimax != null) {
bwimax = wimax.isConnected();
}
if (mobile != null) {
if (mobile.isConnected() || wifi.isConnected() || bwimax) {
return true;
}
} else {
if (wifi.isConnected() || bwimax) {
return true;
}
}
return false;
}
@SuppressLint("NotifyDataSetChanged")
@Override
public boolean onContextItemSelected(@NonNull MenuItem item) {
final int position = adapter.getPosition();
String img_result = mArrayList.get(position).getCharacter_image();
String name_result = mArrayList.get(position).getCharacter_nickname();
String charLevel_result = mArrayList.get(position).getCharacter_level();
String class_result = mArrayList.get(position).getCharacter_class();
String itemLevel_result = mArrayList.get(position).getCharacter_itemLevel();
String server_result = mArrayList.get(position).getCharacter_server();
switch (item.getItemId()) {
case R.id.character_update:
Toast.makeText(context, name_result + "의 정보를 갱신하였습니다." , Toast.LENGTH_SHORT).show();
mArrayList.remove(position);
deleteNumber(img_result, name_result, charLevel_result, class_result, itemLevel_result, server_result);
nickname = "https://lostark.game.onstove.com/Profile/Character/";
nickname += name_result;
AddCharacter(nickname);
//OnRecreateBackgroundTask();
break;
case R.id.character_delete:
Toast.makeText(context, name_result + "의 정보를 삭제하였습니다." , Toast.LENGTH_SHORT).show();
mArrayList.remove(position);
deleteNumber(img_result, name_result, charLevel_result, class_result, itemLevel_result, server_result);
//OnRecreateBackgroundTask();
break;
}
Intent in = new Intent(context, MainActivity.class);
startActivity(in);
return true;
}
}
중간중간 쿼리문을 수동으로 입력해서 DB를 관리하는걸 볼 수 있다.
음......
그만 알아보도록 하자.
다시 Room으로 돌아와서, Room은 이런 귀찮은 작업을 네트워크 통신 코드처럼 Builder 클래스를 사용해서 우리에게 익숙한 형태로 제공하게 된다.
Room 구현
Database Class
@Database(entities = [Recent::class], version = 1)
abstract class RecentDatabase : RoomDatabase() {
abstract fun recentDao() : RecentDao
companion object {
private var instance : RecentDatabase? = null
//데이터베이스 생성 빌더
@Synchronized
fun getInstance(context: Context) : RecentDatabase? {
if (instance == null) {
synchronized(RecentDatabase::class) {
instance = Room.databaseBuilder(
context.applicationContext,
RecentDatabase::class.java,
"Recent_table"
).build()
}
}
return instance
}
}
}
@Database Annotation을 사용해서 Database 클래스라는 것을 정의한다.
(entities = [Recent::class], version = 1)을 통해 엔티티로 사용될 클래스를 정의해주면 된다.
추상 클래스로 정의하고 RoomDatabase()를 확장하도록 하고, 내부에는 companion object로 Builder 클래스를 생성했다.
Kotlin의 companion object == Java의 static 변수와 비슷하다.
@Synchronized Annotation을 사용해서 싱글톤 객체로 생성하고 Recent_table이라는 이름으로 테이블을 생성했다.
Entities
@Entity
data class Recent (var search : String, var createdTime : Long = System.currentTimeMillis()) {
@PrimaryKey(autoGenerate = true)
var id = 0
}
data class로 정의하고 @Entity Annotation을 사용한다.
각 인스턴스는 테이블에 있는 행 하나를 의미한다.
여기서는 search라는 행과 createdTime이라는 행을 생성한다.
(createdTime은 생성된 시간이 필요해서 추가했다.)
@PrimaryKey를 사용하면 해당 변수를 기본키로 사용을 하게 되고 autoGenerate = true를 사용하면 자동으로 번호순대로 생성해준다.
Data Access Objects (DAO)
@Dao
interface RecentDao {
/**
<데이터 추가 로직>
검색어 입력
기존 데이터 있는지 확인 후 기존 데이터 있으면 삭제 (최근 검색어에 같은 검색어의 데이터가 없도록)
최근 10개 데이터 로드
가져온 데이터가 10개 미만인 경우 -> 새로운 데이터만 추가
가져온 데이터가 10개인 경우 -> 가장 오래된 데이터 삭제 후 새로운 데이터 추가 (검색어가 증가할 때 마다 DB에 쓰지 않는 데이터가 쌓이는 것을 방지)
최근 검색어 클릭
클릭한 검색어 DB에서 삭제 (클릭한 검색어가 최근 순으로 정렬 되도록)
메인 액티비티로 돌아가, 클릭한 검색어로 검색 실행
검색어 입력 로직을 그대로 실행
*/
@Insert
suspend fun insert(recent: Recent) {
//기존에 저장된 데이터 있는지 확인
val existingData = getRecent(recent.search)
//기존 데이터 있으면 삭제
existingData?.let { deleteRecent(recent.search) }
//최근 10개 데이터 로드
val recentList = getRecentList()
//가져온 데이터 10개 미만인 경우, 새로운 데이터 추가
if (recentList.size < 10) {
insertRecent(recent)
return
}
//가져온 데이터 10개인 경우, 가장 오래된 데이터 삭제한 후 새로운 데이터 추가
deleteOldRecentList()
insertRecent(recent)
}
//데이터 추가
@Insert
suspend fun insertRecent(recent: Recent)
//최근 10개 데이터 로드
@Query("SELECT * FROM Recent ORDER BY createdTime DESC LIMIT 10")
suspend fun getRecentList(): List<Recent>
//최근 10개 제외한 데이터 삭제
@Query("DELETE FROM Recent WHERE id NOT IN (SELECT id FROM Recent ORDER BY createdTime DESC LIMIT 10)")
suspend fun deleteOldRecentList()
//검색어로 기존에 저장된 데이터 로드
@Query("SELECT * FROM Recent WHERE search=:search")
suspend fun getRecent(search: String): Recent?
//검색어로 기존에 저장된 데이터 삭제
@Query("DELETE FROM Recent WHERE search=:search")
suspend fun deleteRecent(search: String)
}
@Dao를 사용하면 Dao 클래스가 생성이 된다.
interface 형태로 제공해야 하고 내부에는 Retrofit에서 Rest API 통신하듯이 쿼리문을 쓰고 함수를 적으면 된다.
위 코드는 최근 검색어를 저장하는 코드이다.
필요한 쿼리문을 작성해서 넣으면 된다.
Room 사용
private val db = RecentDatabase.getInstance(application.applicationContext)!!
아까 설정했던 Room Database에서 getInstance를 불러와 빌더 클래스를 불러와준다.
//DB에 검색어 저장
private fun insertRecentSearch(searchText: String) {
viewModelScope.launch {
db.recentDao().insert(Recent(searchText))
}
}
//검색어로 검색
fun search(searchText: String) {
//검색어가 있을 때만 검색
if (searchText.isNotEmpty()) {
viewModelScope.launch() {
//최근 검색어 DB에 저장
insertRecentSearch(searchText)
//페이징 데이터로 값 넘기기
getSearchData(searchText)
}
}
}
그리고 DAO에서 설정했던 함수를 불러와 쿼리문을 실행해서 DB를 관리하면 된다.
그러면 다음과 같이 DB에 값이 들어간 것을 확인할 수 있다.
참고
https://developer.android.com/training/data-storage/room?hl=ko
'Android' 카테고리의 다른 글
[Android] Paging3 Frame Drop Issue (0) | 2023.04.23 |
---|---|
[Android] Hilt - Android DI Library (0) | 2023.04.15 |
[Android] Paging3 사용해보기 (0) | 2023.04.08 |
[Android] Navigation Bar와 Bottom Sheet를 같이 쓸 때 문제점과 주의사항 (0) | 2023.03.24 |
[Android] LiveData VS Kotlin Flow with Chat GPT (1) | 2023.03.11 |