출처: http://d2.naver.com/helloworld/8725603
컴파일 시점에 프로그램의 잠재적인 결함을 찾아내려는 시도는 매우 다양합니다.
미정의 동작(undefined behavior)이 최소화되도록 언어 문법이나 코어 API를 정의하는 방법부터
API의 오용을 방지하도록 제약을 두는 방법, 외부 분석 도구를 활용하는 방법까지 수많은 방법이 있습니다.
Java에서는 특히 애너테이션으로 메타데이터를 정의해 활용하는 기법이 발달했습니다.
Android에서는 Support Annotations 라이브러리가 제공하는 애너테이션을 활용해 프로그램의 결함을 찾아낼 수 있습니다.
Support Annotations 라이브러리는 Android 플랫폼에 특화된 기능을 제공할 뿐만 아니라 Android Studio와 빌드 도구에
통합됐습니다. 그렇기 때문에 코드를 작성할 때 편리하게 애너테이션을 사용할 수 있습니다.
이 글에서는 Android의 Support Annotations 라이브러리와 사용 방법을 설명하고,
주요 애너테이션을 간략하게 살펴보겠습니다.
애너테이션과 결함 탐지
애너테이션은 Java 소스 코드에 추가할 수 있는 메타데이터로, @
기호로 시작한다.
애너테이션을 이용하면 Java의 문법으로는 담을 수 없는 정보를 표현할 수 있다.
상위 클래스의 메서드를 오버라이드한다는 정보를 표현하는 @Override 애너테이션이 대표적이다.
추가 정보를 문법적으로 표현할 수 있기 때문에 Java에서는 API의 의도를 애너테이션으로 명시하고 결함 탐지에 활용하는
기법이 발달했다. 예를 들어 개발자가 어떤 메서드의 반환값을 null 검사 없이 사용하는 상황을 가정하자.
Java의 문법만으로는 그 메서드가 null을 반환하는지 파악하기 어려우므로 결함이 있는지 알 수 없다.
그러나 메서드에 'null을 반환할 수 있다'는 의미의 애너테이션이 붙어 있다면 잠재적 결함이 있음을 명확하게 알 수 있게 된다.
Java의 애너테이션
애너테이션을 결함 탐지에 활용하는 대표적인 도구로 FindBugs와 Checker Framework가 있다.
그러나 이러한 애너테이션은 Java 표준에 정의된 내용이 아니기 때문에 외부 도구가 제공하는 애너테이션을 사용하거나
직접 애너테이션을 선언해야만 한다.
JSR 305: Annotations for Software Defect Detection
JSR 305는 애너테이션을 활용해 결함을 탐지하려는 시도를 보여 주는 대표적인 명세다.
@NonNull 애너테이션과 @CheckForNull 애너테이션 등 널리 쓰이는 애너테이션을 두루 정의한다.
그러나 아직 Java 표준에 반영되지는 않은 상태다. 현재 휴면(dormant) 상태라 당분간도 반영이 요원할 것으로 보인다.
대신 com.google.code.findbugs:annotations를 의존성으로 등록하면 FindBugs가 제공하는 레퍼런스 구현을 사용할 수 있다.
JSR 308: Annotations on Java Types
JSR 308은 JSR 305와 함께 사용할 수 있는 별개의 명세로, JEP 104로 Java 8에 포함됐다.
이 명세는 새로운 애너테이션을 추가한 것은 아니다.
단지 다음과 같이 제네릭 타입 파라미터나 타입캐스팅 등 타입 자체에 애너테이션을 붙일 수 있도록 허용한다.
Map<@NonNull String, @NonEmpty List<@Readonly Document>> files;
myString = (@NonNull String)myObject;
IntelliJ IDEA Annotations
IntelliJ IDEA는 오래전부터 @NotNull 애너테이션, @Nullable 애너테이션 등을 별도로 제공했다
("Annotating Source Code" 참고). 규약을 따르지 않으면 IDE에서 바로 오류로 표시하므로 활용성이 뛰어나다.
그뿐만 아니라 IntelliJ IDEA 14 버전에서는 추론을 통해 @NotNull 애너테이션과 @Nullable 애너테이션을 자동으로 붙여 주는
기능도 추가됐다. 그러나 org.jetbrains.annotations 패키지에 별도로 정의한 애너테이션을 사용하기 때문에 IntelliJ IDEA를
사용하지 않는 다른 환경에서는 애너테이션을 사용하기 어렵다.
IntelliJ IDEA Annotations에 관한 더 자세한 내용은 "External Annotations"와 "Automatic @NotNull/@Nullable/@Contract inference in IntelliJ IDEA 14"를 참고한다.
Android의 애너테이션
Android 환경에서 애플리케이션을 개발할 때는 일반적인 Java 환경에서 애플리케이션을 개발할 때보다 잠재적인 문제의
소지가 더 많다고 볼 수 있다.
Android SDK API의 문제 원인
Android 애플리케이션을 개발할 때 문제를 일으킬 수 있는 원인 중 하나는 프리미티브 타입 사용이다.
Android는 성능을 위해 Java에서 클래스 구조로 제공하는 편의성과 안전성을 희생하고 프리미티브 타입을 주로 사용한다.
다음과 같은 경우가 프리미티브 타입을 사용하는 예다.
- Enum이나 EnumSet을 사용하지 않고 int 타입 상수와 비트 플래그 연산을 사용해 열거형 값을 다룬다.
- px, dp, color 등 단위를 별도의 타입으로 만들지 않고 프리미티브 값만 사용하거나 단위를 나타내는 별도의 값
(TypedValue)과 쌍으로 사용한다. - 각종 리소스 데이터를 패킹해 R 클래스가 int 타입의 아이디로 모두 관리한다.
또 다른 원인은 Android에 있는 다음과 같은 제약이다. Java의 시맨틱 분석으로는 이 제약을 어겼는지 검증할 수 없다.
- UI 작업은 메인 스레드에서만 실행할 수 있고, 네트워크 작업은 메인이 아닌 스레드에서만 실행할 수 있다.
- 일부 클래스의 메서드(특히 UI 관련 클래스의 라이프사이클 메서드)를 오버라이드할 때는 상위 클래스의 메서드를
반드시 호출해야 한다. - 64K 제한에 따라 애플리케이션의 전체 메서드 개수는 65,536개 이내여야 한다.
프리미티브 타입을 사용하면 리소스의 아이디를 넣어야 할 곳에 정수 리터럴을 넣는 등의 실수가 일어날 수 있다.
64K 제한 때문에 ProGuard를 사용해 애플리케이션의 코드를 최적화(minify)하면 리플렉션에 문제가 생길 수도 있다.
이런 문제는 런타임 오류를 일으켜 애플리케이션이 비정상으로 종료되게 하므로 개발자가 코드를 작성할 때 신경을 써야 한다.
Android Support Annotations 라이브러리의 등장
Android가 제공하는 Support Annotations 라이브러리의 애너테이션을 활용하면 프리미티브 타입 사용이나 Android의 제약으로 발생할 수 있는 문제를 예방할 수 있다. Support Annotations 라이브러리는 @NonNull 애너테이션처럼 널리 쓰이는
애너테이션을 비롯해 Android 플랫폼에 특화된 독특한 기능도 제공한다.
Support Annotations 라이브러리는 Android Support Library 19.1부터 제공됐다. 22.2에서는 새로운 애너테이션이 대폭
추가되고 IDE 및 빌드 도구와 통합이 이루어져 IDE 내에서 바로 오류를 표시하거나 빌드 시점에 Lint를 통해 오류를 검사하는
기능도 제공된다. Android 6.0 Marshmallow부터는 프레임워크 소스 코드 내에서도 Support Annotations 라이브러리가
적극적으로 사용되고 있다.
Support Annotations 라이브러리 사용 방법
Support Annotations 라이브러리를 Android Studio와 빌드 도구에 통합해 사용하려면 다음과 같은 환경이 필요하다.
- Support Annotations 라이브러리 22.2.0 버전 이상
- Android Studio 1.3 버전 이상
- Android Plugin for Gradle 1.3 버전 이상
- Android SDK Platform-tools 23 버전 이상
아직 프리뷰 버전인 Android N에 추가된 @Dimension 애너테이션, @Px 애너테이션, @RequiresApi 애너테이션 등을
사용하려면 다음과 같은 환경이 필요하다.
- Support Annotations 라이브러리 24.0.0 버전 이상
- Android Studio 2.2 버전 이상
- Android Plugin for Gradle 2.2 버전 이상
- Android SDK Platform-tools 24 버전 이상
Gradle의 의존성 파일에 다음과 같이 support-annotations
를 추가하면 android.support.annotation 패키지의 애너테이션을
사용할 수 있다. support-v4 22.1.0 이상을 사용하고 있다면 support-v4에 support-annotations 패키지가 이미 포함되어
있으므로 의존성을 별도로 명시하지 않아도 된다.
dependencies {
compile 'com.android.support:support-annotations:23.4.0'
}
이후 애너테이션의 규약을 위반한 결함이 탐지되면 Android Studio의 소스 코드 편집 창에 바로 경고나 오류가 나타난다.
Android Studio의 Analyze > Inspect Code 메뉴로 정적 분석을 실행할 수도 있다.
Gradle의 lint 태스크를 실행시켜 생성된 보고서에서도 분석 결과를 볼 수 있다.
Support Annotations 라이브러리가 제공하는 애너테이션
Support Annotations 라이브러리가 제공하는 주요 애너테이션을 간략하게 살펴보겠다.
@NonNull, @Nullable
@NonNull 애너테이션과 @Nullable 애너테이션은 null 값 처리에 관련된 애너테이션이다.
@NonNull 애너테이션은 값이 'null'이 아니라는 것을 나타낸다.
예를 들어 @NonNull 애너테이션이 붙은 변수에 null 값을 대입하면 경고가 나타난다.
@Nullable 애너테이션은 값이 'null'일 수 있다는 것을 나타낸다.
예를 들어 @Nullable 애너테이션이 붙은 변수를 null 검사 없이 사용하면 경고가 나타난다.
다음 코드는 필드, 파라미터, 메서드에 @NonNull 애너테이션과 @Nullable 애너테이션을 사용한 예다.
class Example {
@NonNull
final Context mContext;
@Nullable
View mView;
Example(@NonNull Context context) {
// context 파라미터에 @NonNull 애너테이션이 붙어 있으므로
// 파라미터의 값이 null이 아닐 것이라 가정한다.
// 그래서 null 검사가 필요 없다.
// if (context == null) {
// throw new NullPointerException("context");
// }
mContext = context;
}
@NonNull
Context getContext() {
return mContext;
}
void setView(@Nullable View view) {
mView = view;
}
@Nullable
View getView() {
return mView;
}
}
// ...
Context context = null;
new Example(context); // WARNING: Argument 'context' might be null
new Example(null); // WARNING: Passing 'null' argument to parameter annotated as @NonNull
new Example(nonNullContext); // OK
View view = getView();
view.getTag(); // WARNING: Method invocation 'view.getTag()' may produce
// 'java.lang.NullPointerException'
@NonNull View mView;
mView = getView(); // WARNING: expression 'getView()' might evaluate to null but
// is assigned to a variable that is annotated with @NonNull
WARNING
표시
다른 애너테이션을 분석한 결과에서는 문제가 있는 부분이ERROR
(오류)로 표시되지만,
@NonNull 애너테이션과 @Nullable 애너테이션을 분석한 결과에서는 문제가 있는 부분이WARNING
(경고)으로 표시된다.
다른 개발자가 사용하는 API를 만든다면 되도록 @NonNull 애너테이션과 @Nullable 애너테이션을 붙이는 것이 좋다.
API를 사용하는 개발자를 위한 문서의 역할을 겸하기 때문이다.
예를 들어 @NonNull 애너테이션이 파라미터에 붙어 있다면 메서드를 호출하는 쪽이 'null이 아닌 값'을 넘길 책임을 진다.
@NonNull 애너테이션이 메서드 선언에 붙어있다면 '반환값이 null이 아님'을 메서드가 보장하므로 메서드를 호출하는 쪽은
반환값을 검사할 필요가 없다.
@Nullable은 그 반대다. @Nullable 애너테이션이 파라미터에 붙었다면 호출되는 메서드가 전달받은 값이 null인지
확인해야 한다. @Nullable 애너테이션이 메서드에 붙었다면 메서드를 호출하는 쪽이 반환값이 null인지 확인해야 한다.
다음 코드는 @NonNull 애너테이션과 @Nullable 애너테이션을 사용해 null 검사에 대한 책임을 명시한 API의 예다.
class CustomView {
@NonNull
CharSequence title = "";
@NonNull
CharSequence text = "";
@Nullable
Drawable background;
// 메서드를 호출하는 쪽이 title 파라미터의 값이 null이 아님을 책임져야 한다.
// 호출되는 메서드는 title 파라미터의 값이 null인지 확인할 필요가 없다.
void setTitle(@NonNull CharSequence title) {
this.title = title;
}
// 메서드를 호출하는 쪽은 text 파라미터에 어떤 값을 넣어도 상관없다.
// 대신 호출되는 메서드가 text 파라미터의 값이 null인지 확인해야 한다.
void setText(@Nullable CharSequence text) {
this.text = text == null ? "" : text;
}
// 메서드를 호출하는 쪽은 반환값이 null인지 확인할 필요가 없다.
@NonNull
CharSequence getTitle() {
return text;
}
// 메서드를 호출하는 쪽이 반환값이 null인지 반드시 확인해야 한다.
@Nullable
Drawable getBackground() {
return background;
}
}
@Nullable 애너테이션을 사용할 때에는 주의해야 한다.
@Nullable 애너테이션이 붙은 변수를 참조하거나 메서드를 호출하는 쪽에서 null 검사를 하지 않으면 반드시 경고가
나타나기 때문이다. @Nullable 애너테이션은 꼭 필요할 경우에만 사용하고 남용하지 않아야 한다.
예를 들어 액티비티의 onCreate() 메서드가 실행될 때 findViewById() 메서드를 통해 뷰를 가져와 필드에 대입하는 패턴은
매우 흔하다. 이때 Java의 로직상 해당 필드는 null일 가능성이 있다. 그러나 Android 컴포넌트의 라이프사이클에 따르면
onCreate() 메서드 실행 이후에는 필드가 null일 위험이 실질적으로 없다. 이런 경우에까지 @Nullable 애너테이션을 붙이면
변수를 참조할 때 null 검사를 반드시 해야 하므로 좋지 않다. 이때에는 @NonNull 애너테이션도 @Nullable 애너테이션도
붙이지 않는 것이 적합하다.
실제로 공식 문서에서도 @NonNull 애너테이션과 @Nullable 애너테이션 중에 반드시 하나를 붙여야 하는 것이 아니라
둘 다 붙이지 않는 방법도 있다고 설명한다("Support Annotations" 참고). 즉, 모든 곳에 @NonNull 애너테이션이나
@Nullable 애너테이션을 붙일 필요는 없다. 결함 탐지 또는 문서화에 도움이 될 때만 적절히 사용하는 것이 좋다.
다음 코드는 @NonNull 애너테이션도 @Nullable 애너테이션도 붙이지 않는 것이 적합한 예다.
class TestActivity extends Activity {
// 클래스 인스턴스 생성 시 null로 초기화되므로 @NonNull 애너테이션을 붙일 수 없다.
View mView;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.test_activity);
mView = findViewById(R.id.test_view);
}
@Override
protected void onResume() {
super.onResume();
// mView에 @Nullable 애너테이션을 붙이면 불필요한 null 검사를 해야 한다.
mView.setText(getCurrentText());
}
}
@StringRes, @DrawableRes, @ColorRes
@StringRes 애너테이션과 @DrawableRes 애너테이션, @ColorRes 애너테이션은 리소스 아이디 관련 애너테이션이다.
요소의 값이 이 애너테이션들이 의미하는 리소스 타입에 해당하는 리소스의 아이디임을 의미한다.
Android의 모든 리소스는 R 클래스에 의해 int 타입의 아이디로 관리된다.
그래서 drawable 타입 리소스의 아이디를 넣어야 할 곳에 문자열 리소스의 아이디나 정수 리터럴을 넣는 등 실수를 범할 수
있는데, @DrawableRes 애너테이션을 활용하면 실수를 방지할 수 있다.
다음 코드는 @StringRes 애너테이션을 사용해 파라미터의 값이 문자열 리소스의 아이디임을 나타내는 예다.
void setMessage(@StringRes int resId) {
mMessage = mContext.getText(resId);
}
// ...
setMessage(R.string.error_retry); // OK
int stringId = R.string.error_retry;
setMessage(stringId); // OK
setMessage(R.color.white); // ERROR: Expected resource of type string
setMessage(1); // ERROR: Expected resource of type string
@AnyRes 애너테이션은 종류에 무관한 리소스를 나타낸다. @AnyRes 애너테이션이 없으면 int 타입의 아무 값이나
넣을 수 있다. 하지만 @AnyRes 애너테이션이 붙어 있으면 반드시 리소스 아이디만 값으로 넣을 수 있다.
@AnyRes 애너테이션을 사용할 때 주의할 점은 이 애너테이션에는 결함 탐지가 작동하지 않는다는 점이다(2016년 7월 기준).
문서화에 활용할 수는 있으나 결함 탐지는 불가능하므로 주의해서 사용한다.
다음 코드는 파라미터에 @AnyRes 애너테이션을 사용하는 예다. setRes(99999999);
와 같이 리소스 아이디가 아닌 값을 넣어도 오류(ERROR
)를 표시하지 않는다.
void setRes(@AnyRes int resId) {
}
// ...
setRes(R.string.error_retry); // OK
setRes(R.color.white); // OK
setRes(99999999); // OK: 실제로는 ERROR여야 한다.
@ColorInt
@ColorInt 애너테이션은 해당 값이 0xff99ff99
와 같은 ARGB 컬러 정수임을 나타낸다.
글자가 비슷한 @ColorRes 애너테이션은 R.color.divider
와 같은 Color Drawable 타입 리소스의 아이디를 나타내므로
혼동하지 않도록 주의한다.
다음 코드는 int 타입 파라미터의 값을 @ColorInt 애너테이션과 @ColorRes 애너테이션으로 명확하게 구분한 예다.
void setColor(@ColorInt int color) {
mColor = color;
}
void setColorRes(@ColorRes int resId) {
setColor(mContext.getResources().getColor(resId));
}
// ...
setColor(0xff99ff99); // OK
setColor(R.color.divider); // ERROR: Should pass resolved color instead of resource id
// here: `getResources().getColor(R.color.divider)`
setColorRes(0xff99ff99); // ERROR: Expected resource of type color
setColorRes(R.color.divider); // OK
@Dimension, @Px
@Dimension 애너테이션과 @Px 애너테이션은 Support Annotations 라이브러리 24.0.0에 추가된 애너테이션이다.
@Dimension 애너테이션은 해당 값의 단위가 dp이거나 sp, 픽셀임을 나타낸다.
애너테이션의 unit 파라미터에 단위를 명시해야 한다. 단위를 명시하지 않으면 픽셀 단위를 의미한다.
@Px 애너테이션은 픽셀 단위를 특화한 애너테이션이다.
@Px 애너테이션이 의미하는 바는 @Dimension(unit = Dimension.PX)
와 동일하다.
그러나 @Px 애너테이션에는 결함 탐지가 작동하나 @Dimension 애너테이션에는 결함 탐지가 작동하지 않는다는 차이가
있다(2016년 7월 기준). 픽셀 단위에는 되도록 @Px 애너테이션을 사용하는 것이 좋다.
다음 코드는 @Dimension 애너테이션과 @Px 애너테이션을 이용해 파라미터의 단위가 각각 sp와 픽셀임을 나타내는 예다.
void setTitleTextSize(@Dimension(unit = Dimension.SP) int textSize) {
titleTextView.setTextSize(textSize);
}
void setTitleHeight(@Px int height) {
titleTextView.setHeight(height);
}
// ...
setTitleTextSize(14); // OK
setTitleTextSize(R.dimen.title_text_size); // OK: 실제로는 ERROR여야 한다.
setTitleHeight(20); // OK
setTitleHeight(getResources().getDimensionPixelSize(R.dimen.title_height)); // OK
setTitleHeight(R.dimen.title_height); // ERROR: Should pass resolved pixel dimension instead of resource id
// here: `getResources().getDimension*(R.dimen.title_width)`
@IntRange, @FloatRange
@IntRange 애너테이션과 @FloatRange 애너테이션은 숫자형 값의 범위를 한정한다.
from 파라미터와 to 파라미터를 지니며, 값의 범위는 from 파라미터의 값부터 to 파라미터의 값 사이다.
투명도에서 0~1 범위의 소숫값(decimal)을 사용하는지, 색상 표현에서 0~255 범위의 정숫값을 사용하는지 혼동하기 쉬운데,
이런 경우에 유용하게 쓰일 수 있다. @IntRange 애너테이션은 int 타입뿐만 아니라 long 타입에도 사용할 수 있다.
마찬가지로 @FloatRange 애너테이션은 float 타입뿐만 아니라 double 타입에도 사용할 수 있다.
다음 코드는 @FloatRange 애너테이션으로 alpha 파라미터의 값이 0.0~1.0 이어야 함을 나타내는 예다.
void setAlpha(@FloatRange(from=0.0, to=1.0) float alpha) {
// if (alpha < 0 || alpha > 1.0f) {
// throw new IllegalArgumentException();
// }
}
// ...
setAlpha(0.5f); // OK
setAlpha(127); // ERROR: Value must be ≥ 0.0 and ≤ 1.0 (was 127)
@Size
@Size 애너테이션은 배열의 크기나 컬렉션의 크기, 문자열의 길이를 한정한다. 크기와 길이를 범위로 제한할 수도 있다.
@IntRange 애너테이션과 @FloatRange 애너테이션의 from 파라미터와 to 파라미터에 상응하는 min 파라미터와
max 파라미터를 사용하면 된다. 또한 multiple 파라미터를 사용하면 크기가 입력한 값의 배수로 제한된다.
배열로 패킹된 n차원 좌표열을 파라미터로 사용할 때 유용하다.
다음 코드는 @Size 애너테이션으로 파라미터가 각각 2차원 좌표와 3차원 좌표열임을 나타내는 예다.
void setVertex2d(@Size(2) int[] vertex) {
// if (vertex.length != 2) {
// throw new IllegalArgumentException();
// }
}
void setLineString3d(@Size(multiple=3) float[] lineString) {
// if (lineString.length % 3 != 0) {
// throw new IllegalArgumentException();
// }
}
// ...
setVertex2d(new float[2]); // OK
setVertex2d(new float[3]); // ERROR: Size must be exactly 2
setLineString3d(new float[3]); // OK
setLineString3d(new float[6]); // OK
setLineString3d(new float[8]); // ERROR: Size must be a multiple of 3 (was 8)
다음 코드는 @Size 애너테이션으로 문자열의 길이를 제한하는 예다.
void setUsername(@Size(min=8, max=12) String username) {
// if (username.length() < 8 || username.length() > 12) {
// throw new IllegalArgumentException();
// }
}
// ...
setUsername("abcdefgh"); // OK
setUsername(""); // ERROR: Length must be at least 8 and at most 12 (was 0)
setUsername("abcdefgh123456"); // ERROR: Length must be at least 8 and at most 12 (was 14)
@IntDef, @StringDef
@IntDef 애너테이션과 @StringDef 애너테이션은 int 타입이나 String 타입으로 이루어진 열거형 값을 Enum이나 EnumSet을
사용할 때처럼 안전하게 사용할 수 있도록 제약한다. Android는 성능을 높이기 위해 Java의 Enum과 EnumSet을 사용하지 않고
정수 상수를 열거형에 주로 사용한다. 이 애너테이션들을 활용하면 잘못된 값을 사용하는 오류를 방지할 수 있다.
사용법은 다른 애너테이션과 달리 조금 복잡하다. 먼저 @interface로 별도의 애너테이션을 선언해야 한다.
선언한 애너테이션에 @IntDef 애너테이션이나 @StringDef 애너테이션을 붙이고 허용 가능한 값을 파라미터로 열거한다.
이 후 파라미터, 필드 등 실제로 열거형 값을 사용하는 부분에 앞서 @interface로 선언한 애너테이션을 붙이면 된다.
다음 코드는 @IntDef 애너테이션으로 공유 상태의 목록을 정의하고, 정의한 목록을 이용해 getter 메서드와 setter 메서드를
정의하는 예다.
static final int INITIALIZED = 0;
static final int STARTED = 1;
static final int ENDED = 2;
static final int CANCELED = 3;
@IntDef({INITIALIZED, STARTED, ENDED, CANCELED})
@Retention(RetentionPolicy.SOURCE)
@interface SharingState {}
@SharingState
int getState() { ... }
void setState(@SharingState int state) { ... }
// ...
setState(STARTED); // OK
setState(STARTED | ENDED); // ERROR: Must be one of: Example.INITIALIZED, Example.STARTED,
// Example.ENDED, Example.CANCELED
setState(0); // ERROR: Must be one of: Example.INITIALIZED, Example.STARTED,
// Example.ENDED, Example.CANCELED
setState(100); // ERROR: Must be one of: Example.INITIALIZED, Eample.STARTED,
// Example.ENDED, Example.CANCELED
@IntDef 애너테이션의 flag 파라미터를 'true'로 지정하면 EnumSet 처럼 비트 플래그를 사용하도록 선언할 수도 있다.
flag 파라미터를 사용하면 각 플래그를 선언하는 부분에 비트 시프트 연산자(<<)를 사용하지 않을 경우에도 주의를 준다.
다음 코드는 @IntDef 애너테이션으로 텍스트의 여러 속성을 비트 플래그로 정의하고,
정의한 속성을 이용해 텍스트의 속성을 설정하는 예다.
static final int NONE = 0;
static final int BOLD = 1;
static final int ITALIC = 1 << 1;
static final int UNDERLINE = 1 << 2;
static final int STRIKETHROUGH = 8; // NOTE: Consider declaring this constant using
// 1 << 3 instead
@IntDef(value = {NONE, BOLD, ITALIC, UNDERLINE, STRIKETHROUGH}, flag = true)
@Retention(RetentionPolicy.SOURCE)
@interface TextDecoration {}
void setTextDecoration(@TextDecoration int textDecoration) {
}
// ...
setTextDecoration(BOLD); // OK
setTextDecoration(BOLD | UNDERLINE); // OK
setTextDecoration(0); // OK: flag = true 경우에는 0을 넣어도 된다.
setTextDecoration(100); // ERROR: Must be one or more of: Example.NONE,
// Example.BOLD, Example.ITALIC,
// Example.UNDERLINE, Example.STRIKETHROUGH
@UiThread, @MainThread, @WorkerThread, @BinderThread
@UiThread 애너테이션과 @MainThread 애너테이션, @WorkerThread 애너테이션, @BinderThread 애너테이션은 스레드 관련 애너테이션이다. 이 애너테이션들이 의미하는 스레드와 동일한 유형의 스레드에서만 해당 메서드를 호출할 수 있도록
제약한다. 단, @UiThread 애너테이션과 @MainThread 애너테이션은 실질적으로 동일하게 취급된다.
다음 코드는 각각 @WorkerThread 애너테이션과 @UiThread 애너테이션을 붙여 메서드를 정의하고,
이 메서드를 여러 스레드에서 호출하는 예다.
@WorkerThread
void backgroundJob() {
}
@UiThread
void uiJob() {
}
// ...
@WorkerThread
void backgroundJob2() {
backgroundJob(); // OK
uiJob(); // ERROR: Method uiJob must be called from the UI
// thread, currently inferred thread is worker
view.setVisibility(View.VISIBLE); // ERROR: Method setVisibility must be called from
// the UI thread, currently inferred thread is
// worker
}
@UiThread
void uiJob2() {
backgroundJob(); // ERROR: Method backgroundJob must be called from
// the worker thread, currently inferred thread is
// UI
uiJob(); // OK
view.setVisibility(View.VISIBLE); // OK
}
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... p) {
backgroundJob(); // OK
uiJob(); // ERROR: Method uiJob must be called from the UI
// thread, currently inferred thread is worker
return null;
}
@Override
protected void onPostExecute(Void aVoid) {
backgroundJob(); // ERROR: Method backgroundJob must be called from
// the worker thread, currently inferred thread is
// main
uiJob(); // OK
}
};
이 애너테이션들을 사용할 때는 호출 스택까지는 분석되지 않으므로 주의해야 한다.
즉, @UiThread 애너테이션이 붙은 메서드를 호출한 메서드에 애너테이션이 없다면 @WorkerThread 애너테이션이 붙은
메서드가 이 메서드를 호출해도 오류가 발생하지 않는다. 따라서 스레드 관련 애너테이션이 붙은 메서드가 있다면
이 메서드를 호출하는 모든 메서드에도 동일한 애너테이션을 붙여야 안전하게 사용할 수 있다.
좀 더 자세한 내용은 "The Shortcomings of Android Thread Annotations"를 참고한다.
다음 예는 @UiThread 에너테이션인 메서드를 애너테이션이 붙지 않은 메서드가 호출하고,
이 메서드를 다시 @WorkerThread 애너테이션이 붙은 메서드가 호출할 때의 분석 결과다.
@UiThread
void uiJob() {
}
@UiThread
void annotatedJob() {
uiJob();
}
void unannotatedJob() {
uiJob();
}
@WorkerThread
void backgroundJob() {
uiJob(); // 정상: 오류 발생
annotatedJob(); // 정상: 오류 발생
unannotatedJob(); // 비정상: 오류 발생 안 함
}
@UiThread 애너테이션이 붙은 uiJob() 메서드를 호출한 unannotatedJob() 메서드를 @WorkerThread 애너테이션이 붙은
backgroundJob() 메서드가 호출했는데도 오류가 발생하지 않았다.
@CallSuper
@CallSuper 애너테이션은 이 애너테이션이 붙은 메서드를 하위 클래스에서 오버라이드할 때는
반드시 상위 클래스의 메서드를 호출하도록 강제한다. 액티비티의 라이프사이클 메서드에도 사용된다.
다음 코드는 foo() 메서드에 @CallSuper 애너테이션을 붙여 foo() 메서드를 오버라이드한 하위 클래스에서
반드시 super.foo() 메서드를 호출하도록 강제하는 예다.
class Super {
@CallSuper
void foo() {
}
void bar() {
}
}
// ...
class Example1 extends Super {
@Override
void foo() { // ERROR: Overriding method should call 'super.foo'
}
@Override
void bar() { // OK
}
}
class Example2 extends Super {
@Override
void foo() { // OK
super.foo();
}
@Override
void bar() { // OK
super.bar();
}
}
class ExampleActivity extends Activity {
protected void onCreate(Bundle saved) { // ERROR: Overriding method should call
// 'super.onCreate'
}
}
@CheckResult
@CheckResult 애너테이션은 이 애너테이션이 붙은 해당 메서드를 호출했을 때는 반드시 메서드의 반환값을 사용하도록
강제한다. 예를 들어 선행 조건이나 상태를 검증하는 메서드의 경우 두 가지 방식으로 구현이 나뉜다.
- 조건 만족 여부를 boolean 타입으로 반환하는 방식(예: Context.checkPermission() 메서드
- 반환 타입은 void고 조건을 만족하지 않을 경우 예외를 발생시키는 방식(예: Context.enforcePermission() 메서드
이렇듯 같은 상태를 검증하는 메서드임에도 동작 방식이 다르기 때문에 1번 방식의 메서드를 호출하고 2번 방식일 것이라
예상하는 실수를 범할 위험이 있다. 이때 1번 방식의 메서드에 @CheckResult 애너테이션을 붙이면 반환값을 사용하는 것이
강제되므로 문제의 소지를 줄일 수 있다. 또한 suggest 파라미터를 지정하면 오류 메시지로 2번 방식의 다른 메서드를
제안할 수도 있다.
다음 코드는 @CheckResult 애너테이션으로 메서드를 호출하고 반환값을 사용하지 않으면 오류를 발생시키는 예다.
@CheckResult
boolean isReady() {
}
@CheckResult(suggest="#checkLoggedIn")
boolean isLoggedIn() {
}
void checkLoggedIn() throws NotLoggedInException {
if (!isLoggedIn()) {
throw new NotLoggedInException();
}
}
// ...
isReady(); // ERROR: The result of 'isReady' is not used
boolean ready = isReady(); // OK
checkLoggedIn(); // OK
if (isLoggedIn()) { ... } // OK
isLoggedIn(); // ERROR: The result of 'isLoggedIn' is not used;
// did you mean to call 'checkLoggedIn'?
context.checkPermission(CALL_PHONE); // ERROR: The result of 'checkPermission' is not used;
// did you mean to call
// 'enforcePermission(String,int,int,String)'?
@RequiresPermission
@RequiresPermission 애너테이션은 이 애너테이션이 붙은 메서드나 인텐트 액션, 콘텐츠 프로바이더를 사용할 때 필요한
권한을 나타낸다. 애너테이션이 나타내는 권한이 AndroidManifest.xm 파일에 설정되지 않았다면 오류가 발생한다.
anyOf 파라미터로 여러 권한 중 한 가지 이상 권한이 필요함을 나타내거나, allOf 파라미터로 모든 권한이 필요함을
나타낼 수 있다. 콘텐츠 프로바이더에는 @RequiresPermission.Read
와 @RequiresPermission.Write
로 읽기/쓰기에 대한
접근 권한을 각각 지정할 수도 있다.
다음 코드는 @RequiresPermission 애너테이션으로 메서드 실행에 필요한 권한을 표시한 예다.
@RequiresPermission(CALL_PHONE);
void callHome() { ... }
@RequiresPermission(anyOf = { ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION })
Location getLocation() { ... }
@RequiresPermission(allOf = { READ_SMS, WRITE_SMS })
void deleteAllSpams() { ... }
@RequiresPermission(BLUETOOTH)
String ACTION = "android.bluetooth.adapter.action.REQUEST_DISCOVERABLE";
@RequiresPermission.Read(@RequiresPermission(READ_HISTORY_BOOKMARKS))
@RequiresPermission.Write(@RequiresPermission(WRITE_HISTORY_BOOKMARKS))
Uri BOOKMARKS_URI = Uri.parse("content://browser/bookmarks");
// ...
callHome(); // ERROR: Missing permissions required by Example.callHome:
// android.permission.CALL_PHONE
getLocation(); // ERROR: Missing permissions required by Example.getLocation:
// android.permission.ACCESS_COARSE_LOCATION or
// android.permission.ACCESS_FINE_LOCATION
deleteAllSpams(); // ERROR: Missing permissions required by Example.deleteAllSpams:
// android.permission.READ_SMS and android.permission.WRITE_SMS
@RequiresApi
@RequiresApi 애너테이션은 Support Annotations 라이브러리 24.0.0에 추가된 애너테이션이다.
이 애너테이션이 붙은 메서드나 클래스를 사용할 때 필요한 최소 API 레벨을 나타낸다.
기존의 @TargetApi 애너테이션과 유사하지만 @RequiresApi 애너테이션은 호출에 필요한 최소 API 레벨을 더 명확하게
표시하는 것이 주 목적이다. 애플리케이션의 minSdkVersion 속성의 값이 @RequiresApi 애너테이션에 명시된 API 레벨보다
낮으면 오류가 발생한다.
다음 코드는 @RequiresApi 애너테이션으로 API의 메서드에 최소 API 레벨을 나타내는 예다.
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
void setTitleElevation(float elevation) {
title.setElevation(elevation);
}
// ...
setTitleElevation(10); // minSdkVersion이 21 이상일 경우 OK
// minSdkVersion이 21 미만일 경우 ERROR: Call requires API level 21 (current min
is 15): Example#setTitleElevation
@Keep
@Keep 애너테이션은 애플리케이션의 소스 코드를 최적화할 때 이 애너테이션 붙은 요소는 제거되거나 이름이 변경되면
안 됨을 나타낸다.
Android 애플리케이션을 배포 버전으로 빌드할 때 ProGuard로 소스 코드를 최적화하는 것이 일반적이다.
리플렉션을 통해 접근해야 하는 요소가 있으면 반드시 ProGuard에 -keep
규칙을 지정해야 한다.
일일이 규칙을 지정하는 대신 @Keep 애너테이션을 붙인 후 이 애너테이션이 붙은 대상은 최적화하지 않도록 설정하면
편리하게 사용할 수 있다.
그러나 애너테이션을 붙이더라도 자동으로 최적화 대상에서 제외되지는 않는다.
별도의 ProGuard 설정을 추가해야 한다(2016년 7월 기준).
다음과 같은 설정을 사용하면 @Keep 애너테이션이 붙은 클래스나 멤버를 최적화하지 않도록 지정할 수 있다.
-keep @android.support.annotation.Keep class *
-keep @android.support.annotation.Keep class * {
*;
}
-keep class * {
@android.support.annotation.Keep *;
}
다음은 위의 ProGuard 설정을 이용해 @Keep 애너테이션의 위치를 바꿔가며 여러 클래스를 최적화한 결과다.
// 애플리케이션 코드
setText(new TextProvider1().getText()
+ " " + new TextProvider2().getText()
+ " " + new TextProvider3().getText());
// 최적화 전
class TextProvider1 {
String getText() { return "1"; }
String getText2() { return "2"; }
}
// 최적화 후
class TextProvider1 {
String a(); // 사용: 최적화됨
// 사용 안 함: 제거
}
// 최적화 전
@Keep
class TextProvider2 {
String getText() { return "1"; }
String getText2() { return "2"; }
}
// 최적화 후
class TextProvider2 {
String getText(); // 사용: 보존
String getText2(); // 사용 안 함: 보존
}
// 최적화 전
class TextProvider3 {
@Keep
String getText() { return "1"; }
String getText2() { return "2"; }
}
// 최적화 후
class TextProvider3 {
String getText(); // 사용: 보존
// 사용 안 함: 제거
}
// 최적화 전
class TextProvider4 {
String getText() { return "1"; }
String getText2() { return "2"; }
}
// 최적화 후
class TextProvider4 {
// 사용 한 함: 제거
// 사용 안 함: 제거
}
// 최적화 전
@Keep
class TextProvider5 {
String getText() { return "1"; }
String getText2() { return "2"; }
}
// 최적화 후
class TextProvider5 {
String getText(); // 미사용: 보존
String getText2(); // 사용 안 함: 보존
}
// 최적화 전
class TextProvider6 {
@Keep
String getText() { return "1"; }
String getText2() { return "2"; }
}
// 최적화 후
class TextProvider6 {
String getText(); // 사용 안 함: 보존
// 사용 안 함: 제거
}
@VisibleForTesting
@VisibleForTesting 애너테이션은 이 애너테이션이 붙은 요소의 가시성(visibility)이 테스트를 위해 완화됐음을 의미한다.
테스트 코드는 비즈니스 로직과 분리하기 위해 별도의 클래스로 만들고 테스트의 대상이 되는 클래스와 같은 패키지에
두는 것이 일반적이다. 그러나 이렇게 하면 테스트 클래스가 테스트 대상 클래스의 private 메서드에 접근할 수 없기 때문에
private 메서드를 테스트할 수 없다.
이때는 다음과 같은 세 가지 방법 가운데 하나를 선택할 수 있다.
- 테스트 클래스에서 접근할 수 있게 가시성을 완화한다.
테스트 코드가 같은 패키지에 위치하므로package-private
으로 두는 것이 일반적이다. - 리플렉션을 이용해 접근한다.
- 테스트하지 않는다.
즉, private 메서드는 테스트 가능한 단위가 아닌 것으로 본다.
대신 이 메서드를 사용하는 다른 공개된 메서드를 테스트한다.
여기서 1번 방법을 선택하면 캡슐화(encapsulation) 원칙에 위반되므로 다른 클래스에서 이 메서드를 호출할 위험이 있다.
이 때 @VisibleForTesting 애너테이션을 붙여 테스트 코드가 아닌 다른 곳에서는 호출해서는 안 된다는 것을 나타낼 수 있다.
@VisibleForTesting 애너테이션은 문서 용도이기 때문에 분석 도구에서 사용되지는 않는다.
다음 코드는 @VisibleForTesting 애너테이션으로 가시성을 완화한 메서드를 JUnit으로 테스트하는 예다.
class SomeService {
public int process(int param) {
int intimidate = prosessStep1(param);
return processStep2(intimidate);
}
// 원래는 private이어야 하지만 테스트를 위해 package-private으로 가시성을 완화한다
@VisibleForTesting
/* private */ int processStep1(int param) {
}
}
class SomeServiceTest {
@Test
void testStep1() {
SomeService service = new SomeService();
// processStep1() 메서드의 가시성이 완화돼 테스트 클래스에서 접근할 수 있다.
assertEquals(0, service.processStep1(1));
}
}
class AnotherService() {
void foo() {
SomeService service = new SomeService();
service.process(0);
// processStep1() 메서드에 접근할 수는 있지만 @VisibleForTesting 애너테이션이 붙어 있으므로 메서드를 호출하면 안 된다.
// service.processStep1(0);
}
}
람다식과 함께 사용할 때 주의점
Android N부터 Jack 툴체인을 통해 Java 8의 기능을 사용할 수 있다. Android N 버전 미만에서도 retrolambda와 같은
플러그인을 적용하면 람다식을 사용할 수 있다. 그러나 람다식을 사용하면 분석 도구가 정상적으로 작동하지 않는다.
이를 방지하려면 번거롭지만 람다 파라미터에 애너테이션과 타입 이름을 표시해야 한다.
다음 코드는 람다식을 사용할 때 @Nullable 애너테이션이 붙은 파라미터에 대한 분석이 정상적으로 이루어지지 않는
상황을 나타낸 예다.
interface LongTaskCallback {
void onCompleted(@Nullable String result);
}
public void longTask(@NonNull LongTaskCallback callback) {
}
// ...
longTask(new LongTaskCallback() {
@Override
public void onCompleted(@Nullable String result) {
showToast(result.length()); // 정상: 경고 발생
}
});
longTask(result -> showToast(result.length())); // 비정상: 경고 발생 안 함
longTask((@Nullable String result) -> showToast(result.length())); // 정상: 경고 발생
메서드에 애너테이션을 붙여야 할 때는 문제가 더 크다. 람다식에는 아예 애너테이션을 붙일 수 없기 때문이다.
메서드 레퍼런스를 사용할 때도 마찬가지다. 따라서 어쩔 수 없이 람다식 대신 기존 방식의 익명 클래스를 사용해야 한다.
다음 코드는 람다식이나 메서드 레퍼런스 대신 익명 클래스를 사용해 정상적인 분석 결과를 얻은 예다.
interface Listener {
@UiThread
void onClick();
}
public void setListener(@NonNull Listener listener) {
}
@UiThread
void uiJob() {
}
@WorkerThread
void backgroundJob() {
}
// ...
setListener(this::uiJob); // 정상: 경고 발생 안 함
setListener(() -> uiJob()); // 정상: 경고 발생 안 함
setListener(this::backgroundJob); // 비정상: 경고 발생 안 함
setListener(() -> backgroundJob()); // 비정상: 경고 발생 안 함
setListener(new Listener() {
@Override
public void onClick() {
backgroundJob(); // 정상: 경고 발생
}
});
마치며
Android Support Annotation 라이브러리의 가장 큰 장점은 Android Studio와 빌드 도구에 통합됐다는 것이다.
잠재적 결함이 소스 코드 편집 창이나 빌드 로그에 바로 오류나 경고로 나타나기 때문에 개발자가 놓치거나 무시하고
지나칠 위험을 줄여준다.
또한 코드 작성자의 의도를 명확하게 나타내는 효과가 있기 때문에 내가 만든 코드를 사용하는 다른 개발자에게도
큰 도움이 된다. 특히 API를 만들어야 한다면 파라미터와 반환 타입을 나타내는 @NonNull 애너테이션과
@Nullable 애너테이션, 메서드를 호출할 수 있는 스레드를 나타내는 @UiThread 애너테이션과 @WorkerThread 애너테이션
정도는 꼭 사용하는 것이 좋다.
Android의 애너테이션을 사용해 코드 결함을 탐지하는 방법에 관한 더 자세한 내용은
" Improving Code Inspection with Annotations " 를 참고한다.
'IT_Programming > Android_Java' 카테고리의 다른 글
[펌] EditText Keyboard show/hide 이벤트 잡기 (Catch soft keyboard show/hidden events in Android) (0) | 2016.11.26 |
---|---|
안드로이드 데이터 바인딩 (0) | 2016.11.11 |
[펌] 안드로이드 웹뷰 CookieSyncManager 정확한 사용법 (0) | 2016.10.24 |
[펌][안드로이드] ConstraintLayout 사용하기 / 안드로이드 컨스트레인트레이아웃. (Android ConstraintLayout) (0) | 2016.09.29 |
[펌] Android Support Library 24.2.0의 버그 (0) | 2016.09.28 |