<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>grensil_.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Thu, 25 Apr 2024 04:13:04 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>grensil_.log</title>
            <url>https://images.velog.io/images/grensil_/profile/3c24a6fb-01dc-481b-81e9-deb5c864b2ad/City-of-Paris-France-Eiffel-Tower_2560x1600.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. grensil_.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/grensil_" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[댓글,대댓글 기능(인스타 같은..)]]></title>
            <link>https://velog.io/@grensil_/%EB%8C%93%EA%B8%80%EB%8C%80%EB%8C%93%EA%B8%80-%EA%B8%B0%EB%8A%A5%EC%9D%B8%EC%8A%A4%ED%83%80-%EA%B0%99%EC%9D%80</link>
            <guid>https://velog.io/@grensil_/%EB%8C%93%EA%B8%80%EB%8C%80%EB%8C%93%EA%B8%80-%EA%B8%B0%EB%8A%A5%EC%9D%B8%EC%8A%A4%ED%83%80-%EA%B0%99%EC%9D%80</guid>
            <pubDate>Thu, 25 Apr 2024 04:13:04 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/grensil_/post/da71e3b8-cd54-40fd-95c8-efb0016350e9/image.gif" alt=""></p>
<blockquote>
<p>위처럼 동작하게 하기 위해 굉장히 많은 시련(?)을 겪었다. 시행착오도 많았고,,
구현하기 전에 먼저 설계를 해야한다는 것을 느꼈다..
먼저 bottomSheetDialogFragment 를 사용해본적이 없었고 
또 그안에서 리싸이클러뷰만 단순히 보이는 것이 아니라, 펼쳐지고 늘어나고... 
페이징 처리하고.. 근데 대댓글도 보여줘야하네..? 아 근데 댓글 추가도 되고 삭제도 된다구요?
아 대댓글도 마찬가지라구요? ㅎㅎㅎㅎ 
아 근데... 수평으로 swipe해서 신고 기능도 있다구요? ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ
네 그냥 인스타 참고해서 최대한 똑같이 만들어볼게요;;;</p>
</blockquote>
<p>많은 과정을 겪었지만.. 여기서는 결론적으로 어떻게 해결했는지 위주로만 적겠습니다ㅎㅎ</p>
<ol>
<li>bottomSheetDialogFragment에서 recyclerView의 적용은 문제 없었다. 그러면 이제 댓글과 대댓글을 어떤식으로 표현해줄 것이냐가 관건이었다. </li>
</ol>
<pre><code>companion object {
        private const val VIEW_TYPE_SKELETON = 0
        private const val VIEW_TYPE_COMMENT_ITEM = 1
        private const val VIEW_TYPE_REPLY_ITEM = 2
    }


     override fun getItemViewType(position: Int): Int {
        return if (isDataLoading) {
            VIEW_TYPE_SKELETON
        } else {
            currentList[position].viewType
        }
    }


     data class CommentData(
        var comment: CommentListItem? = null,
        var viewType: Int = VIEW_TYPE_SKELETON,
        var isClamped: Boolean = false,
    )</code></pre><p>-위 3개면 이해갈까요? view_type을 상수로 정해놓고 CommentData라는 내가 사용할 비즈니스 로직에 필요한 data 객체를 이용해서 전달해주었다. 그러면 CommentData 가 갖고있는 viewType에 따라 다른 viewType을 그릴것이다.</p>
<p>-commentAdapter 내에서 item의 type을 3개로 정의했다. 왜 3개냐면 스켈레톤, 댓글, 대댓글인 경우 3개 이다. </p>
<pre><code> override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when (viewType) {
            VIEW_TYPE_SKELETON -&gt; {
                val skeletonView = LayoutInflater.from(parent.context)
                    .inflate(R.layout.item_comment_skeleton, parent, false)
                return SkeletonViewHolder(ItemCommentSkeletonBinding.bind(skeletonView))
            }

            VIEW_TYPE_COMMENT_ITEM -&gt; {
                val itemView = LayoutInflater.from(parent.context)
                    .inflate(R.layout.item_comment, parent, false)
                return CommentItemViewHolder(
                    ItemCommentBinding.bind(itemView),
                    itemCommentLikeClickListener,
                    itemCommentProfileClickListener,
                    itemReplyWriteClickListener,
                    itemReplyShowClickListener,
                    itemReplyExpandClickListener
                )
            }

            VIEW_TYPE_REPLY_ITEM -&gt; {
                val itemView = LayoutInflater.from(parent.context)
                    .inflate(R.layout.item_reply, parent, false)
                return ReplyItemViewHolder(
                    ItemReplyBinding.bind(itemView),
                    itemReplyLikeClickListener,
                    itemReplyProfileClickListener
                )
            }

            else -&gt; throw IllegalArgumentException(&quot;Unknown view type: $viewType&quot;)
        }
    }</code></pre><p>위처럼 만들어놓고 이제 view단에서 타입 잘 전달해주고, 리스트와 포지션을 제 때에 제값으로만 주면 된다. adapter 구현 보다는 view에서 비즈니스 로직이 굉장히 꼬일 수 있는데 그걸 잘 처리하는게 관건인 것 같다.(당시엔 못 느꼈지만 완성한 지금입장에서 느끼는 회고)</p>
<ol start="2">
<li>자 그러면 어떻게 어떤 식으로 전달하고,전달 받았는지 </li>
</ol>
<pre><code>private fun addNewCommentItems(comments: List&lt;CommentListItem&gt;) {
        binding.commentRcview.visibility = View.VISIBLE
        binding.noItemTv.visibility = View.GONE
        binding.noItemTv2.visibility = View.GONE

        commentAdapter?.setDataLoading(false)

        val newCommentList = comments.flatMap {
            listOf(
                CommentData(
                    comment = it.copy(
                        comment = it.comment?.copy(
                            parentId = it.comment?.id,
                            replyPage = 0,
                            isExpand = false,
                            remainReply = it.comment?.replyCount,
                            isNew = false,
                            replies = emptyList()
                        )
                    ), viewType = VIEW_TYPE_COMMENT_ITEM
                )
            )
        }
        noMoreItemInList = newCommentList.isEmpty()

        newCommentList.forEachIndexed { index, item -&gt;
            commentList.add(item)
        }
        binding.commentRcview.apply {
            (adapter as CommentAdapterV2).apply {
                submitList(commentList) {
                    notifyItemRangeChanged(
                        0,
                        commentList.size
                    )
                }
            }
        }
        isLoading = false
    }</code></pre><p>comment List 한 페이지당 collect 될때 실행하는 메소드이다.
천천히 살펴보면.. 우선 visible,gone 처리는 리스트 없을때 역으로 gone,visible 처리 해놓은게 있어서..
언제든 다시 요청했을때 그사이 댓글이 생겼을 수 있딴 생각에 일일이 visible 처리도 굳이? 해놓았다.
그리고 setDataLoading(boolean: isLoading) 이라는 adapter내에 내가 메소드로 스켈레톤을 표현하거나 해제하기도한다.
그다음부터는 본격적으로 비즈니스로직인데... 이걸 useCase나 viewModel 에서 할까 싶었지만 이 viewModel은 특히나 다른 videoList 에서 다른 안드 개발자 분들도 쓰시는 viewModel 이기도하고,, useCase도 마찬가지.. 그래서 그냥 view단에서 비즈니스 로직을 짰다.(나쁜가?)</p>
<p>먼저 flatmap 메소드 내에서 백단에서 오는 list는 내가 원하는 식으로 바꾸었다. (Entity -&gt; DTO -&gt; DAO를 얻는 과정을 여기서 했다고 보면 될듯, CommentData를 만들때 여러 파라미터들을.. 내가 추가해서 사용하고 있다. 아래 Comment 클래스 참고)</p>
<pre><code>
@Parcelize
data class Comment(
    val id: String? = null,
    val content: String? = null,
    var likeCount: Int? = null,
    var replyCount: Int? = null,
    var liked: Boolean = false,
    val createdAt: String? = null,
    var parentId: String? = null,/** 백엔드 데이터 아님! 댓글 비즈니스 로직에 필요한 파라미터 **/
    var replyPage: Int? = 0,/** 백엔드 데이터 아님! 댓글 비즈니스 로직에 필요한 파라미터 **/
    var remainReply: Int? = 0,/** 백엔드 데이터 아님! 댓글 비즈니스 로직에 필요한 파라미터 **/
    var isExpand: Boolean? = false,/** 백엔드 데이터 아님! 댓글 비즈니스 로직에 필요한 파라미터 **/
    var isNew: Boolean? = false,/** 백엔드 데이터 아님! 댓글 비즈니스 로직에 필요한 파라미터 **/
    var isLastReply: Boolean? = false,/** 백엔드 데이터 아님! 댓글 비즈니스 로직에 필요한 파라미터 **/
    var replies: List&lt;CommentListItem&gt;? = null /** 백엔드 데이터 아님! 댓글 비즈니스 로직에 필요한 파라미터 **/
) : Parcelable</code></pre><ol start="3">
<li>자 그러면 댓글은 전달했으니, 대댓글은 어떻게 전달하느냐? 만약 10번째와 11번째 댓글 사이의 대댓글이 불려지는데, 이 대댓글이 5개다?! 그러면 10번쨰 (대댓글 0<del>4번쨰 요소 추가-&gt; 11번째</del> 15번쨰)가 되고 원래 11번쨰 댓글은 16번째 요소가 되는거다!</li>
</ol>
<pre><code> var replies: List&lt;CommentListItem&gt; = uiState.data.first.map { it.toCommentListItem() }
                            val targetComment = commentList.find { it.comment?.comment?.id == uiState.data.second }
                            targetComment?.comment?.comment?.replies?.forEach { originComment -&gt;
                                replies.forEach { newComment -&gt;
                                    if (originComment.comment?.id == newComment.comment?.id) {
                                        replies = replies.minus(newComment)
                                    }
                                }
                            }
                            addReplyItems(uiState.data.second, replies)


</code></pre><pre><code> private fun addReplyItems(targetCommentId: String, replies: List&lt;CommentListItem&gt;) {
        val targetComment = commentList.find { it.comment?.comment?.id == targetCommentId }
        var targetCommentPosition = commentList.indexOfFirst { comment -&gt;
            comment.comment?.comment?.id == targetCommentId
        }

        if(targetCommentPosition ==-1 || targetCommentPosition&gt;=commentList.size) return

        commentList[targetCommentPosition].comment?.comment?.replies =
            commentList[targetCommentPosition].comment?.comment?.replies?.plus(replies) ?: replies
        commentList[targetCommentPosition].comment?.comment?.replies?.forEach {
            it.comment?.parentId = targetCommentId
        }
        commentList[targetCommentPosition].comment?.comment?.replyPage = targetReplyPage
        commentList[targetCommentPosition].comment?.comment?.remainReply =
            commentList[targetCommentPosition].comment?.comment?.remainReply?.minus(replies.size)
        commentList[targetCommentPosition].comment?.comment?.isExpand = true

        isLoading = false
        commentAdapter?.setDataLoading(false)
        when (isReplyBtnFromComment) {
            true -&gt; {
                /** comment btn **/
                val newReplyList = replies.flatMapIndexed { index, comment -&gt;

                    /** comment의 id가 내가 가진 replies 중에 같은 id를 같고 있으면 skip**/
                    listOf(
                        CommentData(
                            comment = comment.copy(
                                comment = comment.comment?.copy(
                                    parentId = targetCommentId,
                                    replyPage = targetReplyPage,
                                    remainReply = targetComment?.comment?.comment?.replyCount?.minus(
                                        replies.size
                                    ),
                                    isExpand = true,
                                    isLastReply = replies.lastIndex == index,
                                    isNew = false
                                )
                            ), viewType = VIEW_TYPE_REPLY_ITEM
                        )
                    )
                }
                noMoreItemInList = newReplyList.isEmpty()

                newReplyList.forEachIndexed { index, item -&gt;
                    commentList.add(targetCommentPosition + 1 + index, item)
                }
                binding.commentRcview.apply {
                    (adapter as CommentAdapterV2).apply {
                        submitList(commentList) {
                            notifyItemRangeChanged(
                                0,
                                commentList.size
                            )
                        }
                    }
                }
                isLoading = false
            }

            else -&gt; {
                /** reply  btn **/
                val newReplyList = replies.flatMapIndexed { index, comment -&gt;
                    listOf(
                        CommentData(
                            comment = comment.copy(
                                comment = comment.comment?.copy(
                                    parentId = targetComment?.comment?.comment?.id,
                                    replyPage = targetReplyPage,
                                    remainReply = targetComment?.comment?.comment?.remainReply ?: 0,
                                    isExpand = true,
                                    isLastReply = replies.lastIndex == index,
                                    isNew = false
                                )
                            ), viewType = VIEW_TYPE_REPLY_ITEM
                        )
                    )
                }

                noMoreItemInList = newReplyList.isEmpty()
                targetCommentPosition += (targetComment?.comment?.comment?.replies?.size?.minus(
                    replies.size
                )) ?: 0
                newReplyList.forEachIndexed { index, item -&gt;
                    commentList.add(targetCommentPosition + 1 + index, item)
                }
                if(targetCommentPosition&gt;commentList.size) return
                commentList[targetCommentPosition].comment?.comment?.isLastReply = false
                binding.commentRcview.apply {
                    (adapter as CommentAdapterV2).apply {
                        submitList(commentList) {
                            notifyItemRangeChanged(
                                0,
                                commentList.size
                            )
                        }
                    }
                }
                isLoading = false
            }
        }
    }</code></pre><p>replies가 collect 될떄 호출하는 로직인데,</p>
<p>먼저 전달하기 전에 여러가지 비즈니스 로직을 거친다. 보면... 마찬가지로 Entity를 dao로 변환하는 과정이 있고,
targetComment는 어떤 댓글 아래에 삽입할지와 그 부모 댓글이 가진 replies를 넣어주는 부분이다.
왜 넣어주냐면... 댓글의 대댓글이 30개인데 30개를 다 불러와서 댓글 30개를 접었다가 다시 펼쳤을 때 -&gt; 다시 api 1페이지부터 요청하는게 너무 별로였다. 인스타도 바로 되기도하고.. 그래서 부모 댓글 안에는 아직 replies를 갖고 있는거다. 또 댓글 12개씩 요청하고 남은 댓글 수라던가.. 남은 댓글이 없으면 숨기기 &lt;-&gt;펼치기가 되어야 하니까 remainReply 등도 표현해주었다.중간부터 when(리플라이버튼에서왔는지) 문이 있을건데 이건.. 댓글 보기 버튼이 comment냐 reply냐에 따라 비즈니스 로직이 달라졌다. (처음에 대댓글을 불러온적이 없었을 때에도 댓글x개 보기가 있어야하고 나중에 대댓글의 마지막 부분에도 표현해야했다). </p>
<pre><code>생각보다 신경써야할게 많았다. 다 적진 않았지만.... 내가 최근에 적은 댓글, 대댓글은 회색으로 하이라이트 표현을 해줘야 했기에 isNew 라는 파라미터도 있었고 commentPage 뿐 아니라 replyPage를 각 요소마다 관리해야했기 때문에 이 부분도 신경써야 했다. 흐흐... 버그가 아예 없는건 아니지만 대체적으로? 잘 동작하고 인스타도 보니 버그가 아예 없는건 아니었다;;; 우리끼린 버그생성기 뷰라고 불리운다... 
수평 swipe는 또 많은 부분? 이기에 일단 여기서 끗!</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[Follow 상태 관리를 위한 노력]]></title>
            <link>https://velog.io/@grensil_/Follow-%EC%83%81%ED%83%9C-%EA%B4%80%EB%A6%AC%EB%A5%BC-%EC%9C%84%ED%95%9C-%EB%85%B8%EB%A0%A5</link>
            <guid>https://velog.io/@grensil_/Follow-%EC%83%81%ED%83%9C-%EA%B4%80%EB%A6%AC%EB%A5%BC-%EC%9C%84%ED%95%9C-%EB%85%B8%EB%A0%A5</guid>
            <pubDate>Thu, 25 Apr 2024 01:41:16 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>우리 회사 앱이 sns의 성격도 강했기 때문에 인스타그램,유튜브,당근 같은 앱을 참고해보았는데, 서버 콜을 일일이 쏘지 않고도 팔로우,좋아요,차단 등의 상태관리가 잘되는 것을 느꼈다. 하긴 유저 입장에서 이미 입력한 동작이 적용되지 않아 보이면 그건 안된거다. 그래서 우리 앱에서도 어떻게 팔로우,좋아요,차단 등의 상태 관리를 할 지 생각해보았다.</p>
</blockquote>
<ol>
<li>첫번째는 그냥 원뎁스를 생각해보았다. 그치만 결론부터 얘기하자면 원뎁스는 그냥 임시방편 느낌이었다.
궁극적으로 우리 앱에서는 추천 유저 리스트 -&gt; 프로필 디테일 -&gt; 비디오 리스트 -&gt; 타인의 프로필 -&gt; 타인의 팔로잉/팔로우 리스트 이 외에도 인박스 뷰에서 팔로잉/해제 등 여러가지 상황이 존재하였는데 이를 이미 불러온 api 리스트에도 적용을 해줘야 했다.</li>
</ol>
<ol start="2">
<li>그러면 로컬로 id(string)와 상태(boolean) 리스트를 관리하고 이를 다른 뷰에 적용하면 되겠다. 싶은 생각이었다. 자 언제나 그렇듯 말로는 쉬웠지만 구체적으로 어떤 방식으로 언제 이 리스트를추가하고 언제 filter를 하고 언제 비워줄지 생각해보았다.</li>
</ol>
<ol start="3">
<li>사용해본 로컬 저장 방식 및 후기 </li>
</ol>
<p>3-1.singleton 클래스</p>
<pre><code>@Singleton
class FollowListStatusHandler @Inject constructor() {

    private val unFollowUserList = mutableListOf&lt;UnFollowInfoStatus&gt;()
    private var followChangeStatus = false

    private val _followChangeStatusFlow: MutableSharedFlow&lt;Boolean&gt; = MutableSharedFlow()
    val followChangeStatusFlow = _followChangeStatusFlow.asSharedFlow()

    fun saveUnFollowInfo(unFollowStatusInfo: UnFollowInfoStatus) {
        val existingStatus = unFollowUserList.find { it.userId == unFollowStatusInfo.userId }

        if (existingStatus != null) {
            val index = unFollowUserList.indexOf(existingStatus)
            unFollowUserList[index].isFollowed = unFollowStatusInfo.isFollowed
        } else {
            unFollowUserList.add(unFollowStatusInfo)
        }
    }
}</code></pre><p>위 처럼 싱글톤 클래스로 리스트를 관리하였다. 사용해본 후기로는 크게 단점?이 없었다. 사실 가장 먼저 떠오른 방법은 roomDB 였는데 이걸 사용하면서 roomDB를 생각하지 않게 되었다. roomDB는 일일이 쿼리 짜야하고. 만약에 version이나 dao가 달라지게 되면.. 등의 상황을 고려했을 때 이 singleton은 성능적으로도 문제 없고 어차피 앱이 꺼지면 자동으로 사라지는 클래스이니까 따로 삭제하거나 하지 않아도 되고 잘 쓰는 방법인지는 모르겠지만 나는 굉장히 만족하면서 썼다. </p>
<p>3-2.preferenceUtil</p>
<pre><code>fun addFollowIdList(userId: String, status: Boolean) {
        val jsonString = getData(&quot;followIdList&quot;)
        if (jsonString.isNullOrEmpty()) {
            val firstJson = JSONObject()
            firstJson.put(userId, status)
            setData(&quot;followIdList&quot;, firstJson.toString())
        } else {
            val existingJson = JSONObject(jsonString)
            existingJson.put(userId, status)
            setData(&quot;followIdList&quot;, existingJson.toString())
        }
    }</code></pre><p>두번째 방법은 prefs에 넣은 방법인데, 내가 아는 한 string이 기본 지원 방식인데, json형식으로 string을 파싱해서 썼다. 다행히? 정상 동작하였고 저장하는 방식만 다를 뿐 전체적인 로직은 똑같다. 싱글톤 방식에 비해 prefs는 자주 사용?하는 방법이니까 개발하는 입장에서 친근했고 친근함을 넘어서 괜스레 안좋은방법?인가 싶은 마음도 있었지만 이 또한 성능은 만족스러웠다. </p>
<ol start="4">
<li>저장하고 나서 적용 시키는 방법</li>
</ol>
<p>사실 이게 제일 주요했다고 본다. 이미 a Fragment에는 리스트가 불러져 와있는 상태에서 b Fragment에서 어떤 액션을 취하고 다시 a Fragment로 돌아왔을 때, 이 뿐 아니라 앞서 얘기했듯.. a-&gt;c-&gt;b-&gt;d-&gt;다시 a 로 돌아왔을 때 c,b,d 에서 한 액션이 어떻게든 a에서 적용되었어야했다.
더 자세히 얘기하면 앱 내에서 액션 스토리가 </p>
<p>a -&gt; c -&gt; b -&gt; d -&gt; a 라고 했을 때
a -&gt; c의 어떤 액션 -&gt; b -&gt; d -&gt; a 일때에는 b,d,a에서  c의 액션이 적용 적용되어야함
a -&gt; c의 어떤 액션 -&gt; b -&gt; d(여기서 c의 액션을 해제) -&gt; a에는 결론적으로 아무 액션이 없는 상태</p>
<p>이런 느낌?으로 유저의 액션을 반영해야했다. 진짜 간단히 얘기하면 팔로우 상태관리라고 해야할까..(uiState 아님)</p>
<p>자 그러면 본격적으로 어떻게 반영시켰느냐.</p>
<pre><code> private fun setRecommendDancerList(it: List&lt;RecommendDancerItem&gt;?) {
        it?.let {
            if (it.isNotEmpty()) {
                noMoreItemInList = false

                it.forEachIndexed { index, item -&gt;
                    recommendDancerList.add(item)
                }
                val savedList = MyApplication.prefs.getFollowIdList()
                savedFollowList = savedList

                recommendDancerList.removeIf { dancerListItem -&gt;
                    savedFollowList.any { saveListItem -&gt;
                        dancerListItem.dancerInfo.id == saveListItem.first &amp;&amp; saveListItem.second
                    }
                }

                binding.recommendDancerRcview.apply {
                    (adapter as RecommendDancerAdapter).apply {
                        submitList(recommendDancerList) { notifyDataSetChanged() }
                    }
                }
            } else {
                noMoreItemInList = true
            }
        }
        isLoading = false
    }</code></pre><p>위 코드는 list가 넘어올때 adapter로 submitList 하는 과정인데 submitList 전에 followList를 가져와서 제거하는 경우이다.(추천 유저를 이전 뷰에서 팔로우 하고 들어온 경우 이미 팔로우한 유저들은 추천리스트에서 보이지 않겠다는 의도) 이 경우는 a 에서 b로 넘어갈떄 b에 대한 액션 처리이지만 반대의 경우도 생각해야 했다.
그렇다는건 api 콜에 의존할게 아니라 내 로직이 정상동작한다면 돌아왔을 때 미리 선반영이 되어있어야 하는건데..</p>
<p>3가지 경우가 있었다.</p>
<p>4-1. 같은 viewModel을 쓰는 경우
같은 viewModel을 쓰는 view면 얘기는 쉽다.같은 viewModel 이니까 collect 시켜서 뷰로 돌아오기도 전에 이미 처리하는 방법. 제일 근본이라고 생각한다.
4-2.registerForActivityResult가 사용이 가능하거나 onResume이 타는 경우
어쩔수 없이 아주 약간의 바뀌는 과정이 살짝 보이긴하지만 eventBus(sharedViewModel)를 쓰고싶진 않았기에.. 이걸 사용했다.
4-3. 콜백
4-2를 사용하지 않게 된 이유이다. 만약 b Fragment 를 add().commit() 한다고 했을때 
bFragment(private val followOnClick ((String,Boolean)-&gt;Unit))</p>
<p>이런식으로 콜백을 넣고 원래의 뷰에서 람다 함수의 액션을 정의했다. 그 액션은 1번에서 말한 저장하거나 하는 로직 말하는 것이다. 개인적으로 4-1이 가능하면 4-1의 방법이 제일 깔끔하고 그렇지 않다고해도 콜백을 이용해서 깔끔하게 처리되는 과정을 굳이 보일 필요없이 즉각 반영이 가능했었다.</p>
<blockquote>
<p>쓰고 나니 굉장히 별거 아니고.. 그냥 리스트 개념만 있으면 될것 같지만 나름 볼륨이 있는 전체 앱 단에서 모든 상태 관리한다는 중압감?이 있었고 결론적으로는 만족하는 성능이다. 자잘한 오류와 시행착오가 있었지만 생략한거라구,,</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android ]인앱 결제 정리 - 1]]></title>
            <link>https://velog.io/@grensil_/inappPurchase</link>
            <guid>https://velog.io/@grensil_/inappPurchase</guid>
            <pubDate>Sun, 19 Mar 2023 05:59:20 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>안드로이드 인앱 결제에 관하여 노베이스부터 구현하기 까지 과정과
코드를 정리할겸 포스팅을 시작해보겠다.
본론부터 정리하자면 우리는 구글 실시간 개발자 알림을 구성하지 못하였기 때문에 최대한 프론트(앱)단에서 할 수 있는 걸 처리해보았다.</p>
</blockquote>
<h2 id="1-구매-로직">1. 구매 로직</h2>
<pre><code> Purchases.sharedInstance.getOfferingsWith { offerings: Offerings -&gt;
            Log.i(&quot;offerings.current!!.availablePackages&quot;, &quot;${offerings.all.values}&quot;)
            OfferList = offerings.all
        }</code></pre><p>우리는 revenueCat 라이브러리를 이용했다. 구글에서 제공해주는 것보다 영수증 처리등 다양한 것을 제공해주기에 쓴 면이 크고 당연히 유료이다.</p>
<p>위의 코드는 revenueCat에 구글콘솔의 인앱결제 상품들을 연결했다는 가정하에 reenueCat에 연결한 인앱 결제 상품들을 가져오는 코드다.</p>
<pre><code>Purchases.sharedInstance.purchasePackageWith(this@SubscribeActivity,
                                    selectedSubscription!!.offering!!,
                                    onError = { error, userCancelled -&gt;
                                        if (!userCancelled) {
                                            Log.i(TAG, error.message)
                                        }
                                    },
                                    onSuccess = { store, customer -&gt;
                                        Log.i(TAG, &quot;&quot;)
                                    })</code></pre><p>위의 코드는 내가 선택한 상품에 대해 구매하는 로직이다.
비동기로 onError,onSucess가 내려오는데 이때 까지만 해도
인앱결제가 이렇게 복잡하고 어려울 줄 몰랐다.(지금생각해보면 당연한거지만..)</p>
<p>단순히 구매만 시켜서는 안되었다.
왜냐? 인앱 결제에는 여러가지 상황이 있다. 크게 4가지로 나누어보았다</p>
<ol>
<li><p>구매했던 상품과 같은 구글스토어 계정이지만 다른 uid로 접속한 경우 -&gt; 이미 다른 계정으로 이용권을 구독중입니다. 구독 중인 아이디를 확인해주세요</p>
</li>
<li><p>구매했던 상품과 다른 구글스토어 계정이지만 같은 uid로 접속한 경우 -&gt; 
이용권 변경이 불가능합니다. 기존에 구매했던 Google 계정이 맞는지 확인해주세요.</p>
</li>
<li><p>같은 스토어,uid 지만 다른 상품을 선택한 경우 -&gt; upgrage/downgrade 로직 진행</p>
</li>
<li><p>3번의 경우이지만 같은 상품을을 선택한 경우 -&gt; 이미 선택한 상품입니다. </p>
</li>
</ol>
<p>이렇게 존재하였다.</p>
<p>자 그러면 어떻게 아느냐</p>
<h2 id="2-유효성-체크">2. 유효성 체크</h2>
<p>위의 4가지 경우를 알기 위해선 최초 구매시 transactionId를 저장했다.
그리고 구매 요청이 있을대마다 플레이스토어의 구매이력을 조회하여서  같은 transactionId가 있는지 없는지를 검사하였다.</p>
<ol>
<li>같은 transacionId가 있다면 revenueCat의 구매이력도 검사한다.
즉 transactionId는 스토어에 대한 유효성 체크이고 revenueCat 구매이력은 uid(앱ID)에 대한 유효성 체크인 것이다.</li>
</ol>
<pre><code>manager = BillingManager(this, object : BillingCallback {
            override fun onBillingConnected() {
                Log.i(TAG,&quot;&quot;)
            }

            override fun onSuccess(purchase: Purchase) {
                Log.i(TAG, &quot;&quot;)
                Purchases.sharedInstance.getCustomerInfo(object : ReceiveCustomerInfoCallback {
                    override fun onError(error: PurchasesError) {
                        Log.i(TAG, error.message)
                    }
                    override fun onReceived(customerInfo: CustomerInfo) {

                        //처음 구독이지만 구글 결제프로필 구매 내역 있음 -&gt; &quot;이미 다른 프로 계정으로 이용권을 구독중입니다. 구독 중인 아이디를 확인해주세요&quot;
                        customerInfo.entitlements.active.forEach {
                            customerSucribeID = it.key
                        }
                        if (customerInfo.entitlements.active.isEmpty()) {
                            //구매 내역 있기 때문에 주문 번호까지 같은지 다른지 체크해야함.. 같다면 앱ID만 다른 경우임
                            DialogUtil.show(
                                this@SubscribeActivity,
                                &quot;이미 다른 프로 계정으로 이용권을 구독중입니다. 구독 중인 아이디를 확인해주세요&quot;,
                                getStringValue(R.string.btn_confirm)
                            ) {}
                        }
                        //이미 구독중
                        else if (customerInfo.entitlements.active[selectedSubscription!!.offering!!.offering] != null) {
                            DialogUtil.show(
                                this@SubscribeActivity,
                                &quot;이미 구독 중인 상품입니다.&quot;,
                                getStringValue(R.string.btn_confirm)
                            ) {}
                        }
                        //upgrade , downgrade -&gt; 구독 정보가 있는 거니까 결제 프로필 상으로도 같다는 조건을 걸자 -&gt; upgrade/downgrade 시키자.
                        else {

                            if (purchase?.orderId == AppHelper.dataStorage.transactionId) {
                                //upgrade 시키면됨
                                Purchases.sharedInstance.purchasePackageWith(this@SubscribeActivity,
                                    selectedSubscription!!.offering!!,
                                    UpgradeInfo(
                                        customerSucribeID,
                                        BillingFlowParams.ProrationMode.IMMEDIATE_WITH_TIME_PRORATION

                                    ),
                                    onError = { error, userCancelled -&gt;
                                        if (!userCancelled) {
                                            Log.i(TAG, error.message)

                                        }
                                    },
                                    onSuccess = { store, customer -&gt;
                                        Log.i(TAG, &quot;&quot;)
                                    })
                            }
                            //앱 ID는 같지만 결제한 정보가 다르다..
                            else {
                                DialogUtil.show(
                                    this@SubscribeActivity,
                                    &quot;이용권 변경이 불가능합니다. 기존에 구매했던 Google 계정이 맞는지 확인해주세요.&quot;,
                                    getStringValue(R.string.btn_confirm)
                                )
                                {}
                            }
                        }
                    }
                })

            }

            override fun onFailure(responseCode: Int) {
                //구매한 이력이 아무것도 없다..


            override fun onUpdated(purchase: Purchase) {
                //TODO
        })</code></pre><p>manager는 구글 결제 라이브러리로써 구글 결제에 관한 처리를 할 수 있는 부분이다. manager가 success를 한다면 이 스토어계정으로 무언가 결제한 이력이 있다는 뜻이다. 그렇다면 revenueCat에게도 물어봐서 transactionID가 동일한지를 체크해보았다. onReceived 콜백 아래에 
3가지 분기가 되어있는데 설명해보자면</p>
<h3 id="스토어-정보가-있는경우-1-customerinfoentitlementsactiveisempty">스토어 정보가 있는경우 1. customerInfo.entitlements.active.isEmpty()</h3>
<p>스토어에는 구매 이력이 있지만 revenueCat에는 비어있다? -&gt; 해당 앱 id로는 결제한적이 없는 것이다.</p>
<h3 id="2-else-if-customerinfoentitlementsactiveselectedsubscriptionofferingoffering--null">2. else if (customerInfo.entitlements.active[selectedSubscription!!.offering!!.offering] != null)</h3>
<p>revenueCat에 구독정보와 현재 선택한 상품 정보가 일치기 때문에 같은 상품을 선택한 경우다.</p>
<h3 id="3-else">3. else</h3>
<p>1,2번도 아닌 경우는 스토어 결제 이력이 있고 동일 상품도 아니기에 upgrade/downgrade를 요청했다고 보았다. 그치만 확신할 수 없기에 transacionId를 검사하는 로직을 추가했다.</p>
<pre><code>else {
    if (purchase?.orderId == AppHelper.dataStorage.transactionId) {
                                //upgrade 시키면됨
                                Purchases.sharedInstance.purchasePackageWith(this@SubscribeActivity,
                                    selectedSubscription!!.offering!!,
                                    UpgradeInfo(
                                        customerSucribeID,
                                        BillingFlowParams.ProrationMode.IMMEDIATE_WITH_TIME_PRORATION

                                    ),
                                    onError = { error, userCancelled -&gt;
                                        if (!userCancelled) {
                                            Log.i(TAG, error.message)

                                        }
                                    },
                                    onSuccess = { store, customer -&gt;
                                        Log.i(TAG, &quot;&quot;)
                                    })
}</code></pre><p>위와 같은 식으로 처리했다.</p>
<p>자 그럼 이제 나머지 경우, 구글 결제 라이브러리에서 실패한 경우에 대한 처리이다.</p>
<pre><code>override fun onFailure(responseCode: Int) {
                //구매한 이력이 아무것도 없다..
                if (responseCode == -1000) {
                    Purchases.sharedInstance.getCustomerInfo(object : ReceiveCustomerInfoCallback {
                        override fun onError(error: PurchasesError) {
                            Log.i(TAG, error.message)

                        }
                        override fun onReceived(customerInfo: CustomerInfo) {
                            //처음 구독이지만 구글 결제프로필 구매 내역도 없음-&gt; 처음 구독 진행시켜
                            if (customerInfo.entitlements.active.isEmpty()) {
                                Purchases.sharedInstance.purchasePackageWith(this@SubscribeActivity,
                                    selectedSubscription!!.offering!!,
                                    onError = { error, userCancelled -&gt;
                                        if (!userCancelled) {
                                            Log.i(TAG, error.message)
                                        }
                                    },
                                    onSuccess = { store, customer -&gt;
                                        Log.i(TAG, &quot;&quot;)
                                    })
                            } //이미 구독중
                            else if (customerInfo.entitlements.active[selectedSubscription!!.offering!!.offering] != null) {
                                DialogUtil.show(
                                    this@SubscribeActivity,
                                    &quot;이미 구독 중인 상품입니다.&quot;,
                                    getStringValue(R.string.btn_confirm)
                                ) {}
                            }
                            // 결제프로필은 없지만 앱 ID는 같음 다른 결제 프로필임 -&gt; &quot;이용권 변경이 불가능합니다. 기존에 구매했던 Google 계정이 맞는지 확인해주세요.&quot;
                            else {
                                DialogUtil.show(
                                    this@SubscribeActivity,
                                    &quot;이용권 변경이 불가능합니다. 기존에 구매했던 Google 계정이 맞는지 확인해주세요.&quot;,
                                    getStringValue(R.string.btn_confirm)
                                ) {
                                }
                            }
                        }
                    })
                }
            }</code></pre><p>마찬 가지로 3가지 부분으로 나누었는데.</p>
<h3 id="스토어-정보가-없는-경우-1-if-customerinfoentitlementsactiveisempty">스토어 정보가 없는 경우 1. if (customerInfo.entitlements.active.isEmpty())</h3>
<p>구매한 이력이 없을 때 revenueCat에도 구매 이력이 없다면 -&gt; 이건 아예 새롭게 구독 시작하는 경우이니 새로운 구매 로직을 진행하였다.</p>
<h3 id="2-else-if-customerinfoentitlementsactiveselectedsubscriptionofferingoffering--null-1">2. else if (customerInfo.entitlements.active[selectedSubscription!!.offering!!.offering] != null)</h3>
<p>그치만 만약 같은 상품이 구독 중이라면? -&gt; 이 경우는 스토어는 결제정보가 없지만 해당 앱id로는 결제정보가 있으므로 -&gt; 이미 구독 중인 상품이라고 표시해주었다. (이 부분은 다른 앱들도 이렇게 처리했더라.. x빙 참고함)</p>
<h3 id="3-else-1">3. else</h3>
<p>자 그다음 else의 경우는 스토어에 결제 프로필은 없지만 앱ID 기준으로는 다른 결제 상품을 구독중인 것임 -&gt; 그래서 &quot;이용권 변경이 불가능합니다. 기존에 구매했던 Google 계정이 맞는지 확인해주세요.&quot; 라고 띄어주었다.</p>
<blockquote>
<p>후우.. 여기까지가 내가 맨처음에 말한 4가지에 대한 로직 처리다!!
아직 update나 취소,환불 처리가 남았지만 나머지는 이어서 해보겠다.
(처음엔 2가지 버전으로 나눌 생각은 없었지만.. 쓰다보니 너무 길다!)</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] ExoPlayer 를 활용한 비디오 리스트,썸네일 표현]]></title>
            <link>https://velog.io/@grensil_/Android-ExoPlayer-%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%B9%84%EB%94%94%EC%98%A4-%EB%A6%AC%EC%8A%A4%ED%8A%B8%EC%8D%B8%EB%84%A4%EC%9D%BC-%ED%91%9C%ED%98%84</link>
            <guid>https://velog.io/@grensil_/Android-ExoPlayer-%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EB%B9%84%EB%94%94%EC%98%A4-%EB%A6%AC%EC%8A%A4%ED%8A%B8%EC%8D%B8%EB%84%A4%EC%9D%BC-%ED%91%9C%ED%98%84</guid>
            <pubDate>Thu, 16 Mar 2023 02:33:09 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>피드 리스트가 유튜브처럼 동영상 목록이 보여지고 스크롤 하다가 멈췄을 때
가장 중간에 위치한 Item를 미리보기 형식으로 재생할 수 있어야 했다.
당연히 스크롤 위치가 바뀐다면 재생되는 영상도 바뀌어야한다. 
또 그러면서 재생중이 아닌 동영상은 썸네일로 표현해줘야 했다. 
비디오에 관한 처리가 처음이었기에 헤맸고 최근 리팩토링한 내용을 기록해보려고한다. 
아직도 완벽하진 않지만 그 간 기록들을 적어보려고 한다.</p>
</blockquote>
<h2 id="1-view---피드-데이터-요청-및-처리">1. View - 피드 데이터 요청 및 처리</h2>
<p>가장 먼저 피드 리스트에 접근하면 viewmodel에게 피드 리스트를 요청해주었다.
참고로 두번째 줄은 room DB를 활용해서 북마크 기능을 만든 것이다.</p>
<pre><code>override fun initData() {
        viewModel.getFeedList(&quot;all&quot;)
        db = FeedStorageDatabase.getInstance(requireContext())!!
    }
</code></pre><p> 위와 같은 식으로 BaseFragment에서 상속받은 initData 메소드를 오버라이딩 해주었다.
그리고 마찬가지로 BaseFragment에서의 bind() 에서 observe 하였다.</p>
<pre><code>viewModel.feedsPrevList.observe(this) {
            feedPrevList = it as ArrayList&lt;FeedListPrevModel&gt;
            resumeFeedList()
        }
</code></pre><p>  viewmodel은 Feedrepository를 타고 api를 요청한다. 받아온 liveData를 view로 가져왔고 resumeFeedList() 메서드를 통해 adapter로 연결해주었다. </p>
<h2 id="2-adapter---피드-리스트의-중앙-item-찾기-및-썸네일비디오-처리">2. Adapter - 피드 리스트의 중앙 item 찾기 및 썸네일,비디오 처리</h2>
<p>  -피드 리스트의 타입이 총 3개였다. 텍스트,이미지,비디오 세 가지다.</p>
<pre><code>   override fun getItemViewType(position: Int): Int {
        //return position
        if (contentList[position].contentimg.isNullOrEmpty() &amp;&amp; contentList[position].contentvideo.isNullOrEmpty()) {
            return TEXT_TYPE
        }
        //사이즈가
        else if (contentList[position].contentvideo!!.contains(&quot;video&quot;)) {
            return VIDEO_TYPE
        } else {
            return IMAGE_TYPE
        }
    }</code></pre><p>  우리는 s3의 url이 비디오 일시에는 항상 video/<del>~</del>.mp4 등으로 오기 때문에 위와 같이 분기를 만들었다. 조금 더 좋은 방법이 있을까 생각해봤지만 오히려 size나 empty,null 등을 체크하는 것보다 정확한 것 같았다.</p>
<pre><code>  override fun onBindViewHolder(
        holder: BaseViewHolder,
        position: Int
    ) {
        when (holder.itemViewType) {
            IMAGE_TYPE -&gt; {
                (holder as FeedPrevImageListViewHolder).bind(contentList[position], position)
            }
            TEXT_TYPE, VIDEO_TYPE -&gt; {
                (holder as FeedPrevThumbnailViewHolder).bind(contentList[position], position)
            }
        }
    }</code></pre><p>  이미지는 여러개를 올릴 수 있어야 했기 때문에 FeedPrevImageListViewHolder 에서는 viewpager2를 사용했고 텍스트/비디오의 경우는 FeedPrevThumbnailViewHolder 를 videoView로 표현해야 했다. (비디오가 없는 경우엔 알아서 text만 표현되게 구현)</p>
<pre><code> fun bind(item: FeedListPrevModel, position: Int) {
            //thumnail을 먼저표현...
            vd.ivThumbnail.layoutParams.height = getHeightSize()
            vd.screenPlayerView.layoutParams.height = getHeightSize()
            processThumbnail(item.contentimg)
            //processVideoDuration(item.contentvideo!!)
            processImage(item.simage, item.writer!!.gender, item.feedlevel)
            processTime(item.time)
            if (item.sname.isNullOrEmpty()) {
                vd.tvNickname.text = &quot;      &quot;
            } else {
                vd.tvNickname.text = item.sname
            }

            processVideo(item.contentprevideo!!)
              ...
  }</code></pre><p> FeedPrevThumbnailViewHolder 안에 있는 bind 함수 중 일부이다.
  가장 먼저 썸네일을 표현하였는데 getHeightSize 는 우린 항상 16:9 비율로 보이길 원했기에 
  이런식으로 코드를 짜서 16:9 화면으로 썸네일이든 비디오든 보여주기로 하였다.
  크기가 맞지 않을 경우 원본 비율을 유지하게끔 centerCrop으로 scaleType을 설정하였다.
  참고로 썸네일은 피드 작성 할때 원본 비디오를 활용해서 ffMpeg 라이브러리를 통해 따왔다.
  이건 나중에 피드 작성에 대해 포스팅 할 때 올려보도록 하겠다.</p>
<pre><code>private fun getHeightSize(): Int {

            var screenWidth = DimenUtil.getWindowWidth(context.resources.displayMetrics)
            var screenHeight = DimenUtil.getWindowHeight(context.resources.displayMetrics)
            return (9 * screenWidth) / 16

        }</code></pre><pre><code>
  fun processVideo(uri: String) {
            // initialize mediaPlayer here

            val trackSelector = DefaultTrackSelector(context).apply {
                setParameters(buildUponParameters().setMaxVideoSizeSd())
            }
            _player = ExoPlayer.Builder(context)
                .setTrackSelector(trackSelector)
                .build()
                .also { exoPlayer -&gt;
                    //setProgress(true)
                    vd.screenPlayerView.player = exoPlayer
                    vd.screenPlayerView.controllerAutoShow = false
                    vd.screenPlayerView.controllerShowTimeoutMs = 2000
                    vd.screenPlayerView.hideController()
                    exoPlayer.volume = 0.0f

                    val mediaItem = MediaItem.fromUri(&quot;${ImageUtil.baseVideoUrl}$uri&quot;)
                    exoPlayer.setMediaItem(mediaItem)
                    exoPlayer.repeatMode = ExoPlayer.REPEAT_MODE_ONE
                    exoPlayer!!.prepare()
                    exoPlayer.addListener(playbackStateListener(this))
                    //exoPlayer!!.prepare()
                    //exoPlayer!!.play()
                } 
        }</code></pre><p>  위 코드는 exoPlayer를 활용해서 비디오를 재생하였는데 
  위 코드의 대부분 내용은 그냥 간단히 어떤 식으로 영상을 재생할지에 대한 세팅이다.
  중요한 부분은 exoPlayer.addListener(playbackStateListener(this)) 이 부분인데 
  이 부분이 바로 재생 중인지 아닌지에 따라 썸네일 , 동영상 재생 을 전환해주는 코어 부분이다.</p>
<pre><code>fun playbackStateListener(viewholder : FeedPrevThumbnailViewHolder) = object : Player.Listener {
        override fun onPlaybackStateChanged(playbackState: Int) {
            when (playbackState) {
                ExoPlayer.STATE_IDLE -&gt; &quot;ExoPlayer.STATE_IDLE      -&quot;
                ExoPlayer.STATE_BUFFERING -&gt; &quot;ExoPlayer.STATE_BUFFERING      -&quot;
                ExoPlayer.STATE_READY -&gt; {
                    &quot;ExoPlayer.STATE_READY      -&quot;
                    val durationInMillis = viewholder._player!!.duration
                    val seconds = (durationInMillis / 1000).toInt() % 60
                    val minutes = (durationInMillis / (1000 * 60) % 60).toInt()
                    val duration = String.format(&quot;%02d:%02d&quot;, minutes, seconds)
                    viewholder.vd.tvRemainTime.text = duration
                }
                ExoPlayer.STATE_ENDED -&gt; &quot;ExoPlayer.STATE_ENDED     -&quot;
                else -&gt; &quot;UNKNOWN_STATE             -&quot;

            }

        }
        override fun onRenderedFirstFrame() {
            super.onRenderedFirstFrame()
            if (viewholder._player == null) return
        }

        override fun onIsPlayingChanged(isPlaying: Boolean) {
            super.onIsPlayingChanged(isPlaying)
            when (isPlaying) {
                true -&gt; {
                    viewholder.vd.ivThumbnail.visibility = View.GONE
                    viewholder.vd.layoutMute.visibility = View.VISIBLE
                    viewholder.vd.layoutRemainTime.visibility = View.VISIBLE
                    viewholder.vd.progressHorizontal.visibility = View.VISIBLE
                    getProgressPercent()
                    //viewholder.vd.screenPlayerView.postDelayed(this::getProgressPercent, 1000)
                }
                else -&gt; {
                    viewholder.vd.ivThumbnail.visibility = View.VISIBLE
                    viewholder.vd.layoutMute.visibility = View.GONE
                    viewholder.vd.layoutRemainTime.visibility = View.GONE
                    viewholder.vd.progressHorizontal.visibility = View.GONE
                }
            }
        }
}</code></pre><p>  가장 먼저 영상 재생이 준비 되면 prepare 호출 이후<br>  -&gt; STATE_READY 가 되기 때문에 영상에 대한
  정보를 가져올 수 있다. 유튜브 처럼 전체 재생 길이를 표현해주고 
  1초씩 줄이길 원했기에 getProgressPercent() 함수로 표현해주었다. (postDelayed 사용) 
  우리는 30분 이 동영상 제한 길이였기 때문에 mm:SS 식으로 표현!</p>
<p>  onIsPlayingChanged 는 재생상태가 바뀔때마다 보여줄 뷰와 숨길 뷰를 처리하면 됐다. 
  썸네일은 무조건 로드되어야 했기 때문에 썸네일 imageView의 
  translationZ=&quot;1dp&quot;로 설정했고 VISIBLE,GONE 처리만 해주었다. 
  비디오는 포커스를 잃었을 때 자동 중지되며 썸네일이 위로 올라오는 방식.</p>
<p>  자 그러면 이제 또 중요한 도대체 어떻게 포커스에 따라 정지하고 재생이 되는지 부분이다.</p>
<pre><code>override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
    super.onAttachedToRecyclerView(recyclerView)
    recyclerView.addOnScrollListener(onScrollListener)
}</code></pre><p>  리싸이클러뷰의 item이 onAttached 되었을 때 스크롤 리스너 하나를 붙였다.(감지 역할)</p>
<pre><code>private val onScrollListener = object : RecyclerView.OnScrollListener() {
        override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
            super.onScrollStateChanged(recyclerView, newState)
            if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                val layoutManager = recyclerView.layoutManager as LinearLayoutManager
                var centerPosition = -1
                val firstCmpPos: Int = layoutManager.findFirstCompletelyVisibleItemPosition()
                val lastCmpPos: Int = layoutManager.findLastCompletelyVisibleItemPosition()
                if (firstCmpPos == 0) {
                    centerPosition = 0
                }
                else if (lastCmpPos == contentList.size - 1) {

                    centerPosition = contentList.size-1
                }
                else {
                    val middle = abs(lastCmpPos - firstCmpPos) / 2 + firstCmpPos
                    if (middle &gt;= 0) {
                        centerPosition = middle
                    }
                }
                if (centerPosition != RecyclerView.NO_POSITION) {
                    var viewHolder = recyclerView.findViewHolderForAdapterPosition(centerPosition)
                    if (viewHolder != null &amp;&amp; viewHolder.itemViewType != IMAGE_TYPE) {
                        viewHolder = viewHolder as FeedPrevThumbnailViewHolder
                        if (viewHolder != currentPlayingViewHolder) {
                            viewHolder._player?.addListener(playbackStateListener(viewHolder))
                            viewHolder._player?.play()
                            currentPlayingViewHolder?.stop()
                            currentPlayingViewHolder = viewHolder
                        }
                    }
                }
            }
        }
    }</code></pre><p> recyclerView.findViewHolderForAdapterPosition(centerPosition) 
 이런 함수가 있는지 몰라서 좀 애먹었는데.. 말그대로 내가 원하는 포지션의 
 viewHolder Item를 찾아주는 고마운? 메서드 였다.
  이걸 이용해서 새롭게 중앙에 위치한 viewHolder를 재생 시켰고,
  이전에 중앙에 위치했던 currentPlayingViewHolder는 stop을 시켰다. 
  그리고 마지막에 currentPlayingViewHolder를 수정해주었다.</p>
<h2 id="3-viewmodel-로직">3. ViewModel 로직</h2>
<pre><code>private val _feedsPrevList: MutableLiveData&lt;List&lt;FeedListPrevModel&gt;&gt; = MutableLiveData()
    var feedsPrevList: LiveData&lt;List&lt;FeedListPrevModel&gt;&gt; = _feedsPrevList

          fun getFeedList(ctype: String) {
//        if(!checkNetworkState())
//            return
            if (loading) {
                showToast(&quot;로딩중&quot;)
                setRefreshDataLoading(false)
                return
            }
            setDataLoading(true)
            feedRepository.getFeedList(ctype).subscribe {
                setDataLoading(false)
                when (it) {
                    is Result.Success -&gt; {
                        _feedsPrevList.value = it.data!!
                    }
                    is Result.Loading -&gt; {}
                    is Result.DataError -&gt; {
                        showToast(&quot;피드를 불러올 수 없습니다\n${it.error?.message}&quot;)
                    }
                }
            }.apply { addDisposable(this) }
        }</code></pre><p>  위는 뷰모델의 피드 리스트를 얻어노는 메서드이다. 평범하다. 코루틴+ 플로우 공부해 놓은게 있는데 언제 적용해보지..?</p>
<h2 id="4-repository-로직">4. Repository 로직</h2>
<pre><code></code></pre><pre><code>fun getFeedList(ctype: String): Observable&lt;Result&lt;ArrayList&lt;FeedListPrevModel&gt;&gt;&gt; {
        if (feedListGetApi == null)
            return Observable.just(Result.DataError(DataException.NoneApi()))
        val apiType: String
        return feedListGetApi.run {
            this!!.getFeedList(
                FeedListReqModel(
                    ctype
                ).also { apiType = it.type }
            ).subscribeOn(Schedulers.computation())
                .doOnNext {
                    doOnNextJob(it, ApiAddressConstant.Feed.FeedPrev, apiType)
                }
                .map {
                    when {
                        !it.checkApiSuccess() -&gt; Result.DataError(DataException.SeverFail(it.message))
                        it.body == null -&gt; Result.DataError(DataException.RequireDataNull(it.message))
                        else -&gt; Result.Success(it.body!!)
                    }
                }
                .onErrorReturn {
                    onErrorReturnJob(it, ApiAddressConstant.Feed.FeedPrev, apiType)
                    Result.DataError(DataException.ServerCrash())
                }
                .toObservable()
                .observeOn(AndroidSchedulers.mainThread())
        }

    }</code></pre><pre><code></code></pre><p>  위는 repository 에서 rxjava를 활용하여 API에 대한 처리하는 코드이다.
  서버 자체에 문제가 있는지, 인터넷 연결 등 서버에는 문제가 없지만 
  다른 에러인지(서버에서 알려줌), 호출에 성공하였는지를 처리하였다. </p>
<blockquote>
<p>  이렇게 구현하니 아직 영상이 많지 않아서 그런지 
전체적으로 만족하는? 성능을 보여주었다.
  여기까지가 최근 리팩토링 한 부분이고 비디오를 가져온 부분에 있어서 
  확실히 더 쾌적해진 느낌이다.
  특히 스크롤 반응 및 영상 재생과 썸네일 전환이 빨라졌다!
  만약 또 버그가 생기거나 개선점을 찾는다면.. 수정해보도록 하겠다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] 빌드 변형 구성하기(BuildConfig + productFlavors)]]></title>
            <link>https://velog.io/@grensil_/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%EB%B9%8C%EB%93%9C-%EB%B3%80%ED%98%95-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0BuildConfig-productFlavors</link>
            <guid>https://velog.io/@grensil_/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%EB%B9%8C%EB%93%9C-%EB%B3%80%ED%98%95-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0BuildConfig-productFlavors</guid>
            <pubDate>Mon, 19 Dec 2022 03:47:48 GMT</pubDate>
            <description><![CDATA[<p>현재 개발중인 앱이 있다. 테스트 할 게 있을 때는 dev 서버에 접속해서 테스트를 하는데,
문제는 dev 서버에 접속하기 위해서는 항상 real 서버를 거쳐야했다.</p>
<p>물론 dev 서버로 한 번 로그인한 경우에는 계속 dev 서버로 이용이 가능하지만, 
매번 코드를 수정하고 새롭게 build 할 때마다 real 서버에서 dev 서버로 
접속해야 하는 번거로움이 있었다.</p>
<p>이럴 때 사용하는게 빌드 변형(같은 프로젝트 내에서 여러 버전의 앱을 빌드)이다. 
빌드 변형을 활용하면 dev / real 버전으로 각각 빌드할 수 있고 빌드 했을 때
어떤 처리를 할 것인지 코드로 짜주면된다.
(dev 일때는 devMode url로 안내, real 일때는 realMode url로 안내)
&nbsp;
&nbsp;
자 그러면 어떻게 만드는지 정리 하도록 하겠다
&nbsp;
&nbsp;</p>
<h3 id="1-먼저-앱-수준의-buildgradle로-가서-productflavors를-구성해야한다-아래와-같이-구성하였다">1. 먼저. 앱 수준의 build.gradle로 가서 productFlavors를 구성해야한다. 아래와 같이 구성하였다.</h3>
<p><img src="https://velog.velcdn.com/images/grensil_/post/2df80252-07db-4f7c-8d49-4e535a2c54df/image.png" alt=""></p>
<p>나는 dimesion, bulidConfigField 만 설정하였는데 이 외에도</p>
<p><strong>flavorDimensions</strong> 는 간략하게 설명하자면 빌드의 구분을 나타냅니다. &quot;api&quot;, &quot;mode&quot;, &quot;version&quot; 등 api 버전에 따른 구분, 앱의 기능에 따른 구분, version에 따른 구분 등을 나타냅니다.</p>
<p><strong>manifestPlaceholders</strong> 는 AndroidManifest.xml 파일에서 ${appLabel} 처럼 사용할 수 있습니다.</p>
<p><strong>applicationIdSuffix</strong> 는 defaultConfig에 명시된 applicationId 뒤에 붙게 됩니다.각각의 version의 ApplicationId 를 구분하게 해줍니다.</p>
<p><strong>buildConfigField</strong>는 BuildConfig라는 클래스에서 호출할 수 있는 값입니다.BuildConfig.##### 으로 사용합니다.</p>
<p><strong>resValue</strong> 는 문자열 정의로 @string/##### 와 같이 사용할 수 있습니다.strings.xml 파일에서 문자열을 정의해서 사용하는 것과 같습니다.
&nbsp;
&nbsp;
&nbsp;</p>
<h3 id="2-buildconfig의-변수-구성">2. BuildConfig의 변수 구성</h3>
<p><img src="https://velog.velcdn.com/images/grensil_/post/fd5fb468-c77e-4382-829d-e2eef51ef251/image.png" alt="">
위와 같이 ISTESTMODE 라는 변수를 만들었다.(변수명은 자기 마음대로)
이제 이 변수를 이용해서 build의 변형을 바꾸면 된다.
&nbsp;
&nbsp;
&nbsp;</p>
<h3 id="3-본인의-코드에-알맞게-적용하기">3. 본인의 코드에 알맞게 적용하기</h3>
<p>나의 경우 시작시에 AppHelper의 serverMode 가 true(true면 real, false면 dev서버 취급) 로 설정되었기에 <img src="https://velog.velcdn.com/images/grensil_/post/733e6556-c5e2-4ff0-b550-5c8dca0a57d7/image.png" alt=""></p>
<p>위와 같이 시작 시 BuildConfig의 ISTESTMODE 값에 따라 serverMode를 설정하도록 하였다.
&nbsp;
&nbsp;
&nbsp;</p>
<h3 id="4-테스트-해보기">4. 테스트 해보기</h3>
<p>자 여기까지 했다면 Build Variants의 Mode를 바꿔 테스트해보자.
<img src="https://velog.velcdn.com/images/grensil_/post/62e76991-1194-4912-b149-d99da48e5652/image.png" alt="">
&nbsp;
보통 왼쪽 하단에 Build Variants가 숨겨져 있으니 클릭하여 build 구성을 바꿔서 빌드 후 테스트해보자.
나의 경우 devRelease로 빌드 하여 접속하면 ISTESTMODE가 false로 바뀌기 떄문에 dev로 잘 접속을 하였고 반대로 realRelease로 빌드하여 접속하면 real로 잘 접속 되었다!
&nbsp;
&nbsp;
참고한 사이트 <a href="https://developer.android.com/studio/build/build-variants?hl=ko&amp;authuser=1">https://developer.android.com/studio/build/build-variants?hl=ko&amp;authuser=1</a>
<a href="https://yongyi1587.tistory.com/42">https://yongyi1587.tistory.com/42</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Firebase 전화번호 인증 + MVVM 패턴 적용]]></title>
            <link>https://velog.io/@grensil_/Android-Firebase-%EC%A0%84%ED%99%94%EB%B2%88%ED%98%B8-%EC%9D%B8%EC%A6%9D-MVVM-%ED%8C%A8%ED%84%B4-%EC%A0%81%EC%9A%A9</link>
            <guid>https://velog.io/@grensil_/Android-Firebase-%EC%A0%84%ED%99%94%EB%B2%88%ED%98%B8-%EC%9D%B8%EC%A6%9D-MVVM-%ED%8C%A8%ED%84%B4-%EC%A0%81%EC%9A%A9</guid>
            <pubDate>Sun, 21 Aug 2022 07:07:56 GMT</pubDate>
            <description><![CDATA[<p>안드로이드를 공부하면서 mvvm패턴에 대해 많이 듣고 공부하였지만 이번 미니 프로젝트를 진행하면서 좀 더 익히게 된 것 같다. </p>
<p><a href="https://medium.com/firebase-tips-tricks/how-to-create-a-clean-firebase-authentication-using-mvvm-37f9b8eb7336">https://medium.com/firebase-tips-tricks/how-to-create-a-clean-firebase-authentication-using-mvvm-37f9b8eb7336</a></p>
<p>위 블로그 글은 Medium에 올려져 있는 firebase auth + mvvm 패턴으로 구글 인증-&gt;로그인 과정을 구현한 것인데 나는 이것을 공부한 뒤 전화번호 인증으로 바꾸어 보았다.</p>
<p>내가 공부한 내용을 정리해보도록 하겠다.</p>
<p><img src="https://velog.velcdn.com/images/grensil_/post/30bb4710-f70e-4f0a-b051-d3e72ccf5542/image.png" alt=""></p>
<p>내가 만든 내용은 간단히 정리하자면</p>
<p>-전화번호를 입력하고 유효한 번호인지 아닌지 판별
-유효하다면 핸드폰으로 sms 인증코드(6자리)를 보낸다.
-인증번호를 입력한 뒤 인증번호가 맞으면 MainActivity 진입</p>
<p>요렇게 정리할 수 있겠다. 막상 정리하고 보니 별거 아닌것 같다.</p>
<p><strong>1. 인증번호 발송 버튼 클릭 시</strong></p>
<pre><code> private fun sendNumber() {
        val phonenumber = binding.edittextPhonenumber.text.toString()
        if(isValidNumber(phonenumber)) {
            authViewModel!!.sendVerifyNumber(this, national_phonenumber!!)
            }
        else{
            Log.i(&quot;AuthActivity:&quot;,&quot;Invalid PhoneNumber&quot;)
        }
    }</code></pre><p>-위 함수는 전화번호를 입력한 뒤 호출되는 함수로써 내가 입력한 전화번호를 입력 받아 isValidNumber()를 통해 유효한 번호인지 검사한 뒤 인증번호를 보낸다.</p>
<p><strong>2. ViewModel의 sendVerifyNumber 함수</strong></p>
<pre><code> fun sendVerifyNumber(context: AuthActivity, phoneNumber: String) {
        authRepository.sendVerifyNumber(context,phoneNumber)
    }</code></pre><p>-ViewModel 안에서는 repository의 함수를 호출하는 연결하는 방식으로 진행하였다. 이 과정에서 따로 livedata나 옵저버 배턴을 쓸 필요는 없다고 느꼈다.(어차피 repo 의 firebase 콜백함수가 코드보냈을 때 코드가 인증되었을 때 등의 이벤트를 감지해주기 떄문)</p>
<p><strong>3. repository의 sendVerifyNumber 함수</strong></p>
<pre><code>fun sendVerifyNumber(context: AuthActivity, phoneNumber: String) {

        val options = PhoneAuthOptions.newBuilder(firebaseAuth)
            .setPhoneNumber(phoneNumber)       // Phone number to verify
            .setTimeout(60L, TimeUnit.SECONDS) // Timeout and unit
            .setActivity(context)                 // Activity (for callback binding)
            .setCallbacks(callbacks)          // OnVerificationStateChangedCallbacks
            .build()
        PhoneAuthProvider.verifyPhoneNumber(options)

    }</code></pre><p>-내가 입력한 phonenumber로 내가 설정한 60L(60초)만큼의 제한 시간을 주어 인증번호를 보낸다.</p>
<p>-PhoneAuthProvider.verifyPhoneNumber(options)를 호출하게 되면 특이사항이 없을 시 firebase의 callback 함수를 타게 된다. callback 함수는 아래에서 설명하도록 하겠다.</p>
<pre><code>private val callbacks  = object : PhoneAuthProvider.OnVerificationStateChangedCallbacks() {
        override fun onVerificationCompleted(credential: PhoneAuthCredential) {
            Log.d(&quot;AuthRepository&quot;, &quot;onVerificationCompleted:$credential&quot;)
            //signInWithPhoneAuthCredential(credential)
        }
        override fun onVerificationFailed(e: FirebaseException) {
            // This callback is invoked in an invalid request for verification is made,
            // for instance if the the phone number format is not valid.
            Log.w(&quot;AuthRepository&quot;, &quot;onVerificationFailed&quot;, e)

            if (e is FirebaseAuthInvalidCredentialsException) {
                // Invalid request
            } else if (e is FirebaseTooManyRequestsException) {
                // The SMS quota for the project has been exceeded
            }
        }
        override fun onCodeSent(
            verificationId: String,
            token: PhoneAuthProvider.ForceResendingToken
        ) {
            Log.d(&quot;AuthRepository&quot;, &quot;onCodeSent:$verificationId&quot;)
            // Save verification ID and resending token so we can use them later
            storedVerificationId = verificationId
            resendToken = token
        }
    }</code></pre><p>-repository 최상단에 콜백을 선언해주었고 이름에서 쉽게 어떤 역할을 하는지 알 수 있겠지만 순서대로 인증이 성공하였을 때, 실패하였을 때, 인증번호를 보냈을 때 탄다.</p>
<p>-onCodeSent를 타게 된다면 자신이 입력한 핸드폰 번호에 실제로 6자리 인증번호가 날라오게 되고 verificationId는 인증번호의 유효성을 체크해주는 id값이라고 보면 되고 resendToken은 재전송시 필요한 토큰이다. 나중에 인증을 체크하고 재전송을 보내기 위해 전역변수에 따로 저장해두었다.</p>
<p><strong>4. 다음 버튼 클릭 시</strong></p>
<p>-인증번호가 왔다면 화면에 인증번호를 6자리를 입력하고 다음버튼을 눌러보자.</p>
<pre><code>private fun next() {
        //다음 눌렀을 때 -&gt; 여기서 내가 입력한 인증번호를 넘겨줌..
        authViewModel!!.verifyPhoneNumberWithCode(binding.edittextAuth.text.toString())
        authViewModel!!.authenticatedUserLiveData!!.observe(this) { authenticatedUser -&gt;
            if (authenticatedUser.isNew) {
                //파이어스토어에 들어가게함
                createNewUser(authenticatedUser)
            } else {
                goToMainActivity(authenticatedUser)
            }
        }
    }</code></pre><p>-credential(인증번호 인증서 같은 역할)을 생성하기 위해 viewmodel의 verifyPhoneNumberWithCode를 호출한다. 이 함수는 내가 입력한 인증번호를 입력하면 제대로 인증번호 6자리를 입력했는지 안했는지 처리 해주는 함수라고 보면 된다.</p>
<p>-또한 처리가 잘 되었을 때에는 authViewModel 내의 LiveData 값인 User data 중에 isNew의 값이 true 이면 새롭게 가입하는 계정이고 false면 이미 존재하는 계정이다. (이 내용은 repository 와 User Class를 확인해봐야 자세히 알 수 있다)</p>
<ul>
<li>따라서 isNew의 값에 따라 새롭게 계정을 만들지 아니면 바로 uid를 갖고 메인으로 진입시킬 지 결정하는 분기문이필요하다.</li>
</ul>
<p><strong>5. ViewModel의 verifyPhoneNumberWithCode 함수</strong></p>
<pre><code>fun verifyPhoneNumberWithCode(number: String) {
        authenticatedUserLiveData = authRepository.verifyPhoneNumberWithCode(number)
    }</code></pre><p>-이 함수가 이제 본격적으로 내가 입력한 핸드폰 번호와 인증코드의 유효성이 맞는지 아닌지를 체크하고 -&gt; 최종적으로는 firestore에 User 데이터를 liveData 넣는 과정이다.</p>
<p>-liveData를 사용하면 메모리 누수 등의 이점이 있다고 한다.(User 데이터가 계속 생성되는 등)</p>
<p><strong>6. Repository의 verifyPhoneNumberWithCode 함수</strong></p>
<pre><code>fun verifyPhoneNumberWithCode(number: String) : MutableLiveData&lt;User&gt; {
        val phoneCredential = PhoneAuthProvider.getCredential(
            storedVerificationId!!,
            number)
        return signInWithPhoneAuthCredential(phoneCredential)
    }</code></pre><p>-repository 에서 이 함수가 호출 되면 인증번호가 유효성이 맞는지 callback 함수가 호출 된다.(onVerificationCompleted 또는 onVerificationFailed)</p>
<pre><code>fun signInWithPhoneAuthCredential(credential: PhoneAuthCredential) :MutableLiveData&lt;User&gt; {
        val authenticatedUserMutableLiveData = MutableLiveData&lt;User&gt;()
        firebaseAuth.signInWithCredential(credential)
            .addOnCompleteListener {  authTask -&gt;
                if (authTask.isSuccessful) {
                    val isNewUser =
                        authTask.result.additionalUserInfo!!.isNewUser
                    val firebaseUser = firebaseAuth.currentUser
                    if (firebaseUser != null) {
                        val uid = firebaseUser.uid
                        val name = firebaseUser.displayName
                        val email = firebaseUser.email
                        val user = User(uid, name, email)
                        user.isNew = isNewUser
                        authenticatedUserMutableLiveData.setValue(user)
                    }
                } else {
                    Log.i(&quot;AuthRepository&quot;, authTask.result.toString())
                }
            }
        return authenticatedUserMutableLiveData
    }</code></pre><p>-fireabaseAuth 상의 인증이 성공한다면 livedata를 온전히 return 할 것이고 그렇지 않다면 비어있는 최초의  authenticatedUserMutableLiveData를 리턴 할 것이다.</p>
<pre><code>class User : Serializable {
    var uid: String? = null
    var name: String? = null
    var email: String? = null

    @Exclude
    var isAuthenticated = false

    @Exclude
    var isNew = false

    @Exclude
    var isCreated = false

    constructor() {}
    internal constructor(uid: String?, name: String?, email: String?) {
        this.uid = uid
        this.name = name
        this.email = email
    }
}</code></pre><p>-User Class 는 위와 같이 IsAuth,isNew,isCreated 등은 false로 초기화 한다. 후에 생성될 떄 true로 설정하여 firestore에 저장한다.</p>
<p><strong>7. Main으로 넘어와서</strong> </p>
<pre><code>private fun next() {
        //다음 눌렀을 때 -&gt; 여기서 내가 입력한 인증번호를 넘겨줌..
        authViewModel!!.verifyPhoneNumberWithCode(binding.edittextAuth.text.toString())
        authViewModel!!.authenticatedUserLiveData!!.observe(this) { authenticatedUser -&gt;
            if (authenticatedUser.isNew) {
                //파이어스토어에 들어가게함
                createNewUser(authenticatedUser)
            } else {
                goToMainActivity(authenticatedUser)
            }
        }
    }</code></pre><p>-Main 내의 observe 함수가 liveData의 값을 실시간으로 감지하여 새로 계정을 생성하던가 메인으로 바로 태우던가 결정한다. (위에 얘기한 부분) </p>
<p>자세한 코드는 </p>
]]></description>
        </item>
    </channel>
</rss>