안드로이드에서는 레이아웃(*.xml)에 작성한 View를 참조할 때 사용하는 가장 기초적인 방법으로 findViewById 메서드를 제공한다. 안드로이드 초기부터 있었던 방법이지만 여러 가지 문제점이 거론되기 시작하면서 다른 방법들로 대체되어 왔고 현재는 DataBinding과 ViewBidning을 가장 많이 사용한다. findViewById를 사용되지 않는 이유 중 하나로 "Null Safety 하지 않다"는 것이 있는데 앞서 말한 두 방식은 이 문제점을 보완하고 있다. Null safety는 무엇인지, findViewById에서 이 문제가 생기는 이유는 무엇인지, 바인딩 방식은 이 문제를 어떻게 보완했는지에 대해서 알아보자.

 

 

 Null Safety(널 안전성)란 참조하려는 객체가 null일 경우 발생할 수 있는 Null Pointer Exception(이하 NPE)으로부터 안전한 코드라는 의미를 가진다. Null Safety의 의미와 앞서 말한 "findViewById가 Null safety하지 않다"는 말의 의미를 다시 해석해보면 "findViewById의 수행 결과로 얻은 객체는 Null로부터 안전하지 않다"고 바꿔말할 수 있을 것이다.

 

 

 findViewById는 "R.id."과 같은 방식으로 현재 프로젝트에 있는 거의 모든 id에 접근할 수 있으며 파라미터로 전달한 id에 대한 view 객체를 반환한다. 이 방식은 말 그대로 모든 id에 접근할 수 있기 때문에 현재 레이아웃에 존재하지 않는, 즉 다른 레이아웃의 view에도 접근할 수 있다.

 

 

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
}

 Activity를 생성하면 기본적으로 콜백 메서드인 onCreate가 주어지는데 두 번째 라인에서 setContentView를 호출하며 레이아웃 id를 파라미터로 전달한다. 이 과정은 Activity의 레이아웃을 객체화하는 과정으로서 이 과정을 정상적으로 거쳐야 화면이 그려짐은 물론, findViewById로 객체에 대한 참조를 얻어와 사용할 때 NPE가 발생하지 않는다. 하지만 아직 생성되지 않은 레이아웃의 View를 호출하면 어떻게 될까?

 

 

TextView tv = findViewById(R.id.sub_activity_text_view);

 MainActivity에서 SubActivity에 있는 textView에 접근해보자. SubActivity는 아직 메모리에 올라가있지 않기 때문에 NPE가 발생해야 할 것이다. 하지만 초기화만 해서는 빌드 단계에서 에러가 잡히지 않는다.

 

 

TextView tv = findViewById(R.id.sub_activity_text_view);
tv.setText("Hello");

 문제는 위와 같이 참조한 객체를 사용할 때 발생한다. setText를 하기 위해서 TextView 객체를 참조했지만 해당 View는 아직 생성되지 않았기 때문에 NPE가 발생한다. 이 문제가 빌드 단계에서 잡히거나 테스트 단계에서 발견될 경우 에러를 초기에 해결할 수 있겠지만 만약 테스트 과정에서도 발견되지 않고 넘어갈 경우 사용자에게 있어서 어플이 강제 종료될 수도 있는 잠재적인 위협이 된다.

 

 

 이것이 findViewById가 Null safety하지 못한 이유다. 반면 데이터 바인딩은 setContentView로 생성한 레이아웃에 있는 View들에 밖에 접근할 수 없다. findViewById와 다르게 접근 범위를 setContentView를 호출하며 전달한 레이아웃(id)으로 제한한 것이다. 

 메모리에 이미지들에 대한 정보(URL, Bitmap, File, etc..)를 가지고 있다가 ImageView의 좌측 영역을 터치하면 이전 이미지를, 우측 영역을 터치하면 다음 이미지를 보여 주는 이른바 이미지 뷰어 기능을 구현하고 있었다. 구현 자체는 매우 간단했지만 현재 이미지에서 다른 이미지를 불러올 때 로딩하는 시간 때문인지 약 0.5초 정도 빈 화면이 표시된 뒤 이미지를 제대로 불러왔다. 걸리는 시간도 문제였지만 화면이 단색이었기 때문에 전환하는 과정에서 화면이 깜빡거리는 느낌을 줘서 보기 불편했다는 점이다.

 

 서버에서 받아오는 이미지의 용량이 문제인가 싶어서 100kb 이하 크기의 이미지를 여러 개 준비하여 로드해보기도 하고 차이를 보기 위해서 5mb부터 최대 20mb 크기의 이미지를 로드해보기도 했는데 용량에 따른 로딩 속도 차이는 조금 있었지만 화면의 깜빡임은 그대로였다. 이미지를 URL로 바로 로드하는 기존 방식에서 Bitmap 객체에 로드하고 그대로 불러오는 방법, 캐시 메모리에 다운로드 했다가 로드하는 방법 등을 모두 시도해봤지만 깜빡거리는 현상은 해결되지 않았다.

 

 이제껏 사용한 어플들에는 이미지 용량이 커서 로딩이 느린 적은 있어도 이렇게 깜빡거리는 현상은 본 적이 없었기에 굉장한 이질감과 불편함을 느꼈다. 용량에 따른 딜레이는 존재하더라도 최소한 이미지 전환에 있어서는 스무스하게 바뀌어야 사용자 입장에서 불편하다고 느끼지 않을 것이라 생각했기 때문에 방법을 찾다가 CustomTarget 클래스를 사용하는 방법을 찾았다. CustomTarget를 사용하는 방식은 into에 이미지를 표시할 객체에 대한 참조를 주는 게 아닌 CustomTarget 클래스를 구현하여 이미지를 로딩한다. 예전에 SimpleTarget 클래스(지금은 사용이 중지된 듯)를 사용해서 구현한 적이 있었지만 바로 view에 대한 참조를 넣는 것과 타겟 클래스를 사용하는 것의 차이를 몰랐기 때문에 잊어버리고 있었던 것 같다. 

 

 


 

 

 

 깜빡거리는 현상이 발생하는 코드(간략화한 예시)

Glide.with(this)
        .load(url)
        .into(binding.imageView);

 

 

 CustomTarget 적용

 URL 로딩

Glide.with(this)
        .asBitmap()
        .load(your_url)
        .into(new CustomTarget<Bitmap>() {
            @Override
            public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) {
                binding.imageView.setImageBitmap(resource);
            }

            @Override
            public void onLoadCleared(@Nullable Drawable placeholder) {
                //
            }
        });

 

 

 내부 저장소, 혹은 캐시 저장소에 있는 파일 로딩

Glide.with(this)
        .asFile()
        .load(file.getPath())
        .into(new CustomTarget<File>() {
            @Override
            public void onResourceReady(@NonNull File resource, @Nullable Transition<? super File> transition) {
                Bitmap bitmap = BitmapFactory.decodeFile(resource.getPath());
                binding.imageView.setImageBitmap(bitmap);
            }

            @Override
            public void onLoadCleared(@Nullable Drawable placeholder) {

            }
        });

 

 

 용량 문제

 서버에서 받아오는 이미지의 용량이 문제인가 싶어서 100kb 이하 크기의 이미지를 여러 개 준비하여 로드해보기도 하고 차이를 보기 위해서 5mb 크기의 이미지를 로드해보기도 했다. 먼저 20mb 크기의 고용량 이미지를 구해서 로드했더니 약 1초 정도의 로딩 시간이 발생하고 10mb는 0.5초 정도, 5mb는 딜레이가 거의 없었다. CoustomTarget을 사용하지 않고 다이렉트로 view의 참조를 줬을 때랑 로딩 속도는 차이가 없었지만 깜빡임이 사라진 것을 확인할 수 있었다. 이미지 용량이 커서 늦게 로딩될 때는 기존 이미지를 표시하고 있는 상태에서 비동기로 로드가 완료되면 이미지가 전환되었다. 이미지가 아예 사라졌다가 다음 이미지를 불러오는 이전과는 확연히 달랐다.

 


 

 Gif 로딩에 관한 기록

Glide.with(this)
        .asGif()
        .load(url)
        .into(new CustomTarget<GifDrawable>() {
            @Override
            public void onResourceReady(@NonNull GifDrawable resource, @Nullable Transition<? super GifDrawable> transition) {
               	binding.imageView.setImageDrawable(resource);
            }

            @Override
            public void onLoadCleared(@Nullable Drawable placeholder) {

            }
        });

 서버에서 이미지를 로드할 때 여러 포맷을 테스트하였는데 JPG, JPEG, BMP, PNG 등은 문제없이 로드 되었지만 GIF만큼은 제대로 로드되지 않았다. 정확히는 into에 다이렉트로 view의 참조를 줄 경우 Gif의 특성인 애니메이션이 원활하게 동작했지만 이미지를 전환할 때 깜빡거리는 현상은 여전했고 이를 해결하기 위해서 CustomTarget을 사용하면 애니메이션이 동작하지 않았다. 테스트에 사용한 Gif 이미지들의 용량은 500kb~3mb이며 코드는 위와 같다.

 

 이미지 전환에 있어서 매끄럽고 빠르게 Gif를 로딩하려면 ImageView+Glide의 조합으로는 무리라는 생각이 들었다. 다른 이미지 형식의 경우 왠만큼 용량이 높아도 메모리 상에(Bitmap 객체) 저장하고 불러오거나 캐시 디렉터리에 저장한 후 불러오면 스무스하게 로드되었다. 하지만 Gif는 Bitmap으로 저장하면 애니메이션이 제대로 동작하지 않고 이를 저장하기 위한 클래스 역시 GifDrawable 정도 뿐이었다. GifDrawable 타입의 리스트를 생성, 서버에서 읽은 gif 파일을 저장한 뒤 불러오기하면 Bitmap 객체 때와 마찬가지로 애니메이션이 동작하지 않았고 파일로 저장하고 이미지 전환을 시도하면 깜빡임을 없앨 수 없었다.

 

 2개 이상의 Gif를 화면에 표시하려면 깜빡이는 문제를 감안하더라도 ImageView+Glide를 사용하거나 ViewPager2를 사용하는 것좋을 듯 하다. 나중에 ImageView+Glide로 스무스하고 빠른 Gif 이미지 전환 방법을 알게 되면 이 파트는 따로 빼내서 포스팅할 예정이다. 

[maxLines] 최대 라인 수 지정하기

 기존 singleLine 속성이 deprecated 됨에 따라 maxLines 속성을 사용한다. 값으로 1을 주면 singleLine 속성을 지정한 것처럼 사용할 수 있다.

<TextView
android:layout_width="match_parent"
android:layout_height="50dp"
android:maxLines="1"
android:text="Hello world"/>

 

 

[ellipsize] 뷰에 표시된 텍스트에 생략 부호 "..." 넣기

 텍스트 뷰에서 정해 놓은 라인 수 혹은 뷰의 크기를 초과할 경우 생략 표시를 추가하기 위해서는 ellipsize 속성을 사용한다. 사용할 수 있는 옵션은 start, middle, end, marquee, none 등이 있다. "..."이 표시되는 위치를 지정하는 값으로서 end를 사용하면 우리에게 가장 익숙한 "안녕하세요. 이 이야기는..."과 같이 문장의 끝 부분이 생략된다. 값의 이름이 직관적이기 때문에 end 이외 설명은 생략한다.

<TextView
android:layout_width="match_parent"
android:layout_height="50dp"
android:maxLines="1"
android:ellipsize="end"/>

 

 

[gravity] 뷰 내에서 텍스트 위치 지정하기

 뷰 내에서 텍스트의 위치를 지정하려면 gravity 옵션을 사용한다. 기본값은 "start | top"으로 좌측 상단으로 되어 있으며 텍스트의 위치는 2가지 이상의 값을 조합해서 만들 수 있다. 예를 들어 텍스트를 뷰의 수직의 중간에 정렬하면서 우측으로 붙이고 싶다면 "end | center_vertical"을 사용할 수 있다. 논리 연산 기호인 OR의 문자 혹은 파이프 문자인 " | "를 사용하여 2개 이상의 값을 중첩한다.

 

 참고로 덧붙이자면 right와 start, left와 end는 같은 값이며 파이프 문자로 두 개 이상의 값을 동시에 사용할 때 파이프라인 앞뒤로 공백을 넣어선 안된다.

 

 속성에 사용할 수 있는 값

  • top, bottom, start(right), end(left)
  • center, center_vertical, center_horizontal
<TextView
android:layout_width="match_parent"
android:layout_height="50dp"
android:gravity="start|center_vertical"
android:text="Hello world"/>

 

 안드로이드와 스프링을 연동한 어플을 만들다가 간만에 이 에러를 만났다. 이 에러는 보안이 적용되지 않은 HTTP 프로토콜로 통신하려할 떄 만날 수 있는 에러이다. 에러 메시지를 살펴보면 "CLEAR TEXT"라는 단어가 등장하는데 이는 직역하면 "평문"이며 암호학에서 암호화되지 않은 정보를 의미한다. 에러 메시지는 말 그대로 "네트워크 보안 정책에 의거하여 로컬 호스트에 대한 평문 전송을 허용할 수 없다"는 의미다. 안드로이드는 9(Level 28)부터 HTTP에 대한 통신을 기본적으로 불허하고 있다. 앞에 한 말들은 HTTP, HTTPS에 대한 개념에 대해 알고 있다면 어렵지 않게 이해할 수 있다. 이해가 되지 않는다면 HTTPS 혹은 SSL(TLS)에 대해서 알아보고 오자.

 

 

 

 이 에러의 해결 방안을 검색하면 요청할 URL의 프로토콜을 HTTP에서 HTTPS로 바꾸어주라는 것을 볼 수 있다.

 

 

 

 데이터(API)를 요청하려는 서버가 공식적으로 API를 제공하고 있다면 프로토콜을 바꾸어주는 것으로 문제가 해결될 것이다. 하지만 요청하려는 서버가 개인(혹은 자신)이 만든 서버이고 보안 설정을 해두지 않았다면 다시 에러 메시지(Failed to connect to localhost/127.0.0.1:8012)를 마주하게 될 것이다. 처음 발생한 에러 메시지는 HTTP 말고 HTTPS를 사용하라는 의미이긴 하지만 서버 단에서 HTTPS에 대한 보안이 설정되어 있지 않다면 클라이언트인 안드로이드 측에서 HTTPS로 데이터를 요청해도 서버와 통신이 불가능한 것이다.

 

 

 기본적으로 서버는 보안이 제대로 적용되어 있어야 한다. 하지만 서버에 대해 익숙하지 않은 사람이나 스프링으로 과제물을 만드는 학생들, 단순히 공부 과정에서 테스트만 하고 싶은 사람에게 보안까지 적용하는 건 다소 무리일 수 있기에 HTTP를 사용할 수 있는 대안이 존재한다.

 

<application
	..
	android:usesCleartextTraffic="true">

 manifests 파일의 <application> 태그 내에 위처럼 android:usesCleartextTraffic="true" 한줄을 추가해주면 통신을 할 때 HTTP 프로토콜을 사용할 수 있다.

 

 

 혼자 사용하는 내부 서버나 과제물같은 수준이라면 위와 같은 설정을 적용시켜 사용해도 문제없다. 필자가 이 에러를 처음 본 건 몇 년 전이지만 가끔 프로젝트를 새로 만들고 테스트할 때 이 에러를 만나기 때문에 뇌도 돌릴 겸 가볍게 포스팅해보았다. 스프링 프로젝트를 기준으로 HTTPS 보안 설정을 하는 포스팅을 할 기회가 있다면 여기에 추가하도록 하겠다.

 

 포스팅이 도움이 되었다면 좋겠습니다.

A problem occurred configuring project ':app'.
> Build was configured to prefer settings repositories over project repositories but repository 'BintrayJCenter' was added by plugin 'realm-android'

* Try:
Run with --info or --debug option to get more log output. Run with --scan to get full insights.

* Exception is:
org.gradle.api.ProjectConfigurationException: A problem occurred configuring project ':app'.
....
Caused by: org.gradle.api.InvalidUserCodeException: Build was configured to prefer settings repositories over project repositories but repository 'BintrayJCenter' was added by plugin 'realm-android'
...
	at io.realm.gradle.Realm$_apply_closure1.doCall(Realm.groovy:89) ...

 

안드로이드 스튜디오에서 realm DB를 사용하려 할 때 위와 같은 에러가 발생했다.

 

프로젝트 수준의 build.gradle에 아래 코드를 추가해주고

repositories {
	...
	jcenter();
}

dependencies {
	...
	classpath "io.realm:realm-gradle-plugin:6.0.2"
}

 

 

어플리케이션 수준의 build.gradle에 아래 코드를 추가해 준 상태에서 발생한 에러였다.

plugins {
    ...
    id 'realm-android'
}

 

 

해결법

좌측의 Gradle Script에서 settings.gradle을 선택한다. settings.gradle 최상단 라인 한 줄을 수정해주는 것으로 정상적으로 동작하였다.

repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)

수정

repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)

 

+ Recent posts