mangab-heo / weather-app

2 stars 0 forks source link

일정 관리 #6

Open mangab-heo opened 3 years ago

mangab-heo commented 3 years ago
메인 태스크 서브 태스크 비고
:heavy_check_mark:프로토 타입 화면 구현 https://github.com/mangab0159/weather-app/issues/6#issuecomment-870250024
:heavy_check_mark:기상 정보 open api 선택 https://github.com/mangab0159/weather-app/issues/6#issuecomment-872038728
:heavy_check_mark:동네예보 조회 api 사용
네트워크 통신 https://github.com/mangab0159/weather-app/issues/6#issuecomment-872119615
응답 데이터 파싱 https://github.com/mangab0159/weather-app/issues/6#issuecomment-873346260
https://github.com/mangab0159/weather-app/issues/6#issuecomment-873363030
https://github.com/mangab0159/weather-app/issues/6#issuecomment-873519062
데이터 뷰에 노출 https://github.com/mangab0159/weather-app/issues/6#issuecomment-874359646
:heavy_check_mark:위치 정보 획득 및 적용 https://github.com/mangab0159/weather-app/issues/6#issuecomment-876279752
위치 권한 획득
위치 정보 적용
현재 시간 적용 https://github.com/mangab0159/weather-app/issues/6#issuecomment-876932779
:heavy_check_mark:초단기 예보 api 사용 https://github.com/mangab0159/weather-app/issues/6#issuecomment-877881896
통신 및 응답 파싱
데이터 뷰에 노출
:heavy_check_mark:미세먼지 조회 api 사용 https://github.com/mangab0159/weather-app/issues/6#issuecomment-882152335
측정소 위경도 구하기
가까운 측정소 찾기
미세먼지 api 호출
:heavy_check_mark:리팩토링 및 에러 처리 https://github.com/mangab0159/weather-app/issues/6#issuecomment-901015450
:heavy_check_mark:위젯 만들기 https://github.com/mangab0159/weather-app/issues/6#issuecomment-951795077
위젯 레이아웃 및
프로바이더 클래스 생성
리스트뷰 적용
새로고침 버튼 적용
:heavy_check_mark:MVVM 아키텍처 적용 https://github.com/mangab0159/weather-app/issues/6#issuecomment-982952224
데이터 바인딩 적용
뷰모델 클래스 생성 및 구현
repository 클래스 생성 및 구현
패키지 구조 정리
hilt 라이브러리를 이용한 의존성 주입
mangab-heo commented 3 years ago

프로토 타입 화면 구현

mangab-heo commented 3 years ago

기상 정보 open api 선택

공공 데이터 포털에서 기상 자료를 제공하는 api를 검색해보니 기상청_동네예보 조회서비스, 기상청_동네예보 조회서비스(2.0), 기상청_중기예보 조회서비스, 기상청_동네예보 통보문 조회서비스를 찾을 수 있었다. 동네예보는 사흘까지의 예보, 중기예보는 사흘부터 10일까지의 예보, 동네예보 통보문은 문장 형식의 예보를 의미하므로 각각의 api를 활용하여 프로젝트를 진행하려 하였다.

각각의 api는 확연히 다른 정보도 제공하지만 많은 부분은 서로 비슷한 정보를 제공하여 어떤 정보를 취사 선택해야 할지 어려움이 있었다. 예를 들어, 동네예보 조회서비스와 동네예보 통보문 조회서비스는 둘 다 강수확률, 날씨코드(하늘 상태), 강수 형태 항목처럼 이름이 같은 항목을 제공한다. 또, 의미는 비슷하나 이름이 다른 항목들도 있는데 예를 들어, 하루 최고기온을 동네 예보 조회서비스에서는 낮 최고기온 항목으로 제공하고 동네예보 통보문 서비스에서는 예상기온 항목으로 제공한다.

그렇다고 서로 겹치는 항목에 대해서 어느 한 쪽이 다른 한쪽을 완전히 대체할 수 있다고 보기 어려운게 두 api가 제공하는 이름이 같아도 측정 기간과 측정 범위가 다르기 때문이다. 동네예보 조회서비스는 한 시간 간격으로 예보가 업데이트되고 측정 범위가 동 단위인 반면 동네예보 통보문 조회서비스는 하루에 3번(05:00, 11:00, 17:00) 업데이트되고 측정 범위가 시 단위이다.

만들고자하는 날씨 앱이 일 최고 기온, 최저 기온, 현재 온도, 날씨를 보여줄 때 사용자가 한 번의 확인을 통해 오늘의 전체적인 기상 정보를 원한다면 동네예보 통보문 조회서비스를 이용해야할 것이고 혹은 조금더 세부적인 기상 정보를 원한다면 동네예보 조회서비스를 이용해야 할 것이다. 이 앱에서는 먼저 동네예보 조회서비스를 이용해 정보를 표시하고 그 이후에 겹치지 않는 다른 api의 정보를 추가할 것이다.

mangab-heo commented 3 years ago

네트워크 통신

오픈 api 서비스 키

동네예보 조회서비스 api는 네 개의 endpoint를 제공하는데 그 중에서 getVilageFcst endpoint를 사용하려고 한다. 사이트가 제공하는 오픈 api 활용 가이드에서 getVilageFcst endpoint의 명세를 보면 요청 파라미터로 serviceKey 항목이 있는 것을 알 수 있는데 이것은 공공데이터포털에서 발급받은 인증키를 의미한다. 실제로 브라우저를 통해 getVilageFcst를 호출하면 서비스 키가 없다는 에러 메시지를 볼 수 있다. 공공데이터포털에서 api 활용신청을 통해 서비스 키를 발급 받은 뒤 다른 파라미터들과 함께 요청을 해야 한다. 클라이언트는 응답자료형식 또한 요청 파라미터의 dataType 항목으로 결정할 수 있는데 XML과 JSON을 지원한다고 나와있다. 이 프로젝트에서는 JSON 형식으로 통신할 것이므로 dataType에 JSON을 지정하여 요청을 보낸다.

Retrofit

이 프로젝트에서 통신할 때 Retrofit 라이브러리를 사용한다. Retrofit 라이브러리는 요청 함수들이 정의된 interface를 Retrofit 인스턴스를 통해 구현한 뒤 Call 인스턴스를 통해 http 요청을 보내는 구조로 되어있다. Retrofit 인스턴스는 new Retrofit.Builder()로 생성된 Builder 인스턴스의 build 메소드를 통해 생성되는데 build 메소드를 호출하기 전에 base url과 json 파싱을 위한 Gson 인스턴스를 설정해준다. 이렇게 생성된 Retrofit 인스턴스는 create 메소드를 통해 요청 함수들이 정의된 인터페이스 인스턴스를 생성할 수 있다. 이를 코드로 표현하면 다음과 같다.

Retrofit.Builder retrofitBuilder = new Retrofit.Builder();    // Retrofit Builder 생성

retrofitBuilder.baseURL(BASE_URL);    // base url 설정
retrofitBuilder.addConverterFactory(GsonConverterFactory.create(gson));    // json 파서 설정

Retrofit retrofit = retrofitBuilder.build();    // Retrofit 객체 생성

// baseURL과 addConverterFactory 메소드는 자기 자신을 반환하기 때문에 체이닝 가능
// new Retrofit.Builder().baseURL(BASE_URL).addConverterFactory(GsonConverterFactory.create(gson)).build();

RetrofitService retrofitService = retrofit.create(RetrofitService.class);    // 요청 함수들이 정의된 인터페이스 객체 생성

요청 함수들이 정의된 인터페이스는 retrofit 라이브러리에 정의된 Call 인터페이스와 어노테이션들을 이용해 정의한다. 예를 들어 BASE_URL/getVilageFcst에 get 방식으로 요청하는 함수를 만들고 싶으면 다음과 같이 코드를 작성하면 된다.

public interface TestService {
     // getVilageFcst를 endpoint로 하는 get 방식 요청
    @GET("getVilageFcst")
    // 응답은 Call 클래스의 타입 파라미터로 넘긴 클래스(MyData) 타입으로 받아짐
    Call<MyData> getTest();
}

인터페이스 인스턴스에 정의된 요청 함수를 호출하면 Call 인스턴스를 반환한다. 이 Call 인스턴스를 통해 실제 http 요청을 할 수 있다. 이 Call 인스턴스는 동기 방식과 비동기방식으로 http 요청을 보낼 수 있는데 각각 execute 메서드와 enqueue 메서드이다. Call 인스턴스가 만들어질 때 http 요청이 보내지는 것이 아니라 execute나 enqueue 메소드가 실행될 때 요청이 보내진다. execute 메서드를 호출하면 응답이 올 때까지 쓰레드는 busy waiting을 하기 때문에 응답 시간이 길어지면 에러가 날 가능성이 있기에 비동기 방식인 enqueue를 사용한다. enqueue를 사용하면 응답이 왔을 떄 온 응답을 처리할 콜백 함수를 정의해야한다. 응답이 성공하면 onResponse함수가 호출되고 실패하면 onFailure 함수가 호출된다. 응답의 body는 gson을 통해 파싱되어 문자열이 아니라 Call 인스턴스에서 정의된 타입의 인스턴스로 만들어진다. Call 인스턴스를 통해 http 요청을 하는 코드는 다음과 같다.

// Retrofit 객체를 통해 TestService 객체 생성
TestService testSerivce = retrofit.create(TestService.class);
// 생성된 TestService 객체를 통해 Call 객체 생성, 이때 http 요청이 보내지는 것이 아니다
Call<MyData> getTestCall = testService.getTest();

// Call 객체의 enqueue 메소드를 통해 비동기 요청
getTestCall.enqueue(new Callback<MyData>() {
            // 응답이 성공했을 때 실행할 로직을 담은 콜백 함수
            @Override
            public void onResponse(Call<MyData> call, Response<MyData> response) {
                MyData fcstResult = response.body();
            }
            // 응답이 실패했을 때 실행할 로직을 담은 콜백 함수
            @Override
            public void onFailure(Call<MyData> call, Throwable t) {
            }
        });
mangab-heo commented 3 years ago

응답 데이터 파싱

Retrofit에서 json 응답의 역직렬화

역직렬화는 http 요청을 통해 받은 응답을 객체로 바꾸는 것을 의미한다. 응답을 json 형식으로 받으면 응답의 body는 json 형태의 문자열이고 역직렬화를 통해 이 문자열을 커스텀 클래스의 인스턴스로 바꿀 수 있다. Retrofit에서 json 응답의 역직렬화를 위해서는 Retrofit 객체에 json 파서를 설정하는 것이다. 이는 Retrofit.Builder 객체의 addConverterFactory 메소드에 Gson 객체를 인자로 넘겨줌으로써 가능하다.

// Gson 객체 생성
Gson gson = new GsonBuilder().setLenient().create();

// Retrofit Builder 생성
Retrofit.Builder retrofitBuilder = new Retrofit.Builder();
// json 파서 설정
retrofitBuilder.addConverterFactory(GsonConverterFactory.create(gson));

이렇게 역직렬화된 응답은 enqueue 메소드의 onResponse 콜백 함수에서 확인할 수 있다. 구체적으로는 onResponse 콜백 함수에서 매개변수로 정의된 Response 타입 객체의 body 메소드를 통해 확인할 수 있다. 역직렬화된 객체는 Retrofit 라이브러리 내부 동작에 의해 Reponse 클래스의 타입 파라미터로 들어온 UserDefinedClass 타입으로 캐스팅 되는데 이때 Call 인터페이스의 타입 파라미터도 UseDefinedClass로 일치해야 한다.

// 요청 함수(getTest)의 리턴 타입인 Call 인터페이스의 타입 파라미터로 UserDefinedClass를 전달
public interface TestService {
    @GET("endpoint")
    Call<UserDefinedClass> getTest();
}
// TestService 타입의 인스턴스 생성
TestService testService = retrofit.create(TestService.class);
// Call<UserDefinedClass> 타입의 인스턴스 생성
Call<UserDefinedClass> getTestCall =  testService.getTest();
// enqueue 메소드를 통해 비동기 http 요청
getTestCall.enqueue(new Callback<UserDefinedClass>() {
                    @Override
                    public void onResponse(Call<UserDefinedClass> call, Response<UserDefinedClass> response) {
                        // response.body()를 통해 UserDefinedClass 타입으로 역직렬화된 객체 참조
                        UserDefinedClass result = response.body();
                    }

                    @Override
                    public void onFailure(Call<FcstResult> call, Throwable t) {
                    }
                });
mangab-heo commented 3 years ago

응답 데이터 파싱

역직렬화된 객체가 캐스팅될 클래스 정의

역직렬화된 객체를 응답 받은 json 데이터 구조와 동일한 구조의 클래스 객체로 사용하기 위해 응답 받은 json 데이터 구조와 동일한 구조의 클래스를 정의해야 한다. 이때 규칙이 있는데 json 응답의 key를 클래스의 프로퍼티로 정의하는 것이다. 예를 들어, 다음 json 데이터에 맞는 클래스는 다음과 같이 정의해야 한다.

{
  "a": {
    "b": "bValue",
    "c": {
      "d": "dValue",
      "e": "eValue"
    }
  }
}
// 응답 전체에 해당하는 클래스, json 형식의 제일 바깥 중괄호에 해당한다.
// 제일 바깥 중괄호는 key, value 한 쌍("a": { ... })을 가지고 있다.
class UserDefinedClass {
    // "a" key에 대응되는 UserDefinedClass 클래스의 a 프로퍼티
    A a;
}
// "a" key의 value에 해당하는 클래스
// "a" key에 대응되는 객체는 key가 "b", "c" 이렇게 두 개이다
class A {.
    // "b" key에 대응되는 b 멤버 변수, "b" key의 value는 객체가 아닌 문자열이기 때문에 b 멤버 변수는 문자열 타입이다.
    String b;
    // "c" key에 대응되는 c 멤버 변수
    C c;
}

// "c" key의 value에 해당하는 클래스
// "c" key에 대응되는 객체는 key가 "d", "e" 이렇게 두 개이다.
class C {
    // "d" key에 대응되는 d 멤버 변수
    String d;
    // "e" key에 대응되는 e 멤버 변수
    String e;
}

json 응답의 구조와 커스텀 클래스의 구조가 일치하지 않을 때 에러가 나는 줄 알았으나 에러가 나지는 않고 대응되는 멤버 변수가 없는 json 객체의 프로퍼티는 무시된다. json 응답을 Object로 클래스로 받아서 확인해보면 json 데이터가 LinkedTreeMap 객체로 저장되어 있는 것을 알 수 있는데 응답이 오면 json 데이터가 LinkedTreeMap 객체로 다 받아지고 난 뒤 이 LinkedTreeMap 객체가 Retrofit 라이브러리 내부에서 커스텀 클래스로 변환되는 과정을 거친다.

mangab-heo commented 3 years ago

응답 데이터 파싱

기상 정보 클래스 정의

동네 예보 조회 서비스(2.0) api를 통해 받은 json 응답과 동일한 구조의 클래스를 바로 사용하는 것은 불편하고 이를 앱에서 사용하기 편한 구조로 바꾸는 작업이 필요하다. 동네 예보 조회 서비스(2.0) api가 제공하는 예보 정보 중 이 프로젝트에서 사용하는 정보는 POP(강수확률), PTY(강수형태), PCP(1시간 강수량), REH(습도), SNO(1시간 신적설), SKY(하늘 상태), TMP(1시간 기온), TMX(일 최고기온), TMN(일 최저기온), WSD(풍속)이다. 이때 TMX과 TMN은 한 시간 단위 예보를 하지 않고 일 단위로 예보를 한다. 예를 들어, 오전 11시에 발표한 예보는 금일 낮 12시부터 모레 밤 12시(글피 0시)까지의 예보를 포함하는데 한 시간 단위로 예보를 하는 TMP 항목의 경우 총 61번의 예보를 하고 TMN 항목의 경우 2번의 예보(내일과 모레)를 TMX 항목의 경우 3번의 예보를(오늘, 내일, 모레) 한다. 이를 고려하여 정의한 클래스(WeatherData)는 다음과 같다.

public class WeatherData {
    // 매 시간별 예보를 담은 리스트
    List<WeatherHour> weatherHours = new ArrayList<>();
    // 일 별 최고, 최저 기온을 담은 배열
    double[] tmx = new double[] { -1, -1, -1, -1 };
    double[] tmn = new double[] { -1, -1, -1, -1 };

    void addWeatherHour(WeatherHour weatherHour) {
        weatherHours.add(weatherHour);
    }
}
// 한 시간 예보 정보
class WeatherHour {
    // 예보 날짜
    String fcstDate = "Missing";
    // 예보 시간
    String fcstTime = "Missing";
    // 강수확률
    int pop = -1;
    // 강수 형태
    String pty = "Missing";
    // 1시간 강수량
    String pcp = "Missing";
    // 습도
    int reh = -1;
    // 1시간 신적설
    String sno = "Missing";
    // 하늘 상태
    String sky = "Missing";
    // 1시간 기온
    double tmp = -1;
    // 풍속
    double wsd = -1;
}
mangab-heo commented 3 years ago

데이터 뷰에 노출

리싸이클러 뷰를 이용한 매 시간별 예보
mangab-heo commented 3 years ago

리싸이클러 뷰를 이용한 매 시간 별 예보

아이템 레이아웃 및 아이템 클래스 정의
mangab-heo commented 3 years ago

리싸이클러 뷰를 이용한 매 시간별 예보

리싸이클러 뷰 어댑터 및 뷰홀더 클래스 정의

리싸이클러 뷰 어댑터는 RecyclerView.Adapter 클래스를 상속받아서 구현하고 3개의 메소드(onCreateViewHolder, onBindViewHolder, getItemCount)를 오버라이딩해야 한다. onCreateViewHolder 메소드에서는 뷰 홀더 객체를 생성하고 리턴하는데 그 전에 LayoutInflator를 이용해 아이템 뷰를 생성하여 뷰 홀더 생성자에 인수로 넘겨준다. onBindViewHolder는 매개 변수로 뷰 홀더와 포지션(int)이 들어오는데 매개 변수로 넘어온 뷰 홀더가 가지고 있는 뷰 객체를 포지션에 맞는 데이터로 업데이트 시킨다. getItemCount 메소드는 어댑터가 가지고 있는 아이템 객체의 개수를 반환한다.

public class RecyclerAdapter extends RecyclerView.Adapter<RecyclerAdapter.RecyItemViewHolder> {
    // 아이템 뷰에 들어갈 들어갈 정보를 담은 리스트
    private List<WeatherHour> itemList;
    // 생성자
    RecyclerAdapter(List<WeatherHour> weatherHours) {
        this.itemList = weatherHours;
    }

    public RecyItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        // 뷰 객체를 만들기 위한 layout inflator 참조
        LayoutInflater layoutInflater = (LayoutInflater) parent.getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        // layout inflator를 이용한 아이템 뷰 객체 생성
        View itemView = layoutInflater.inflate(R.layout.recycler_item, parent, false);
        // 아이템 뷰를 매개변수로 뷰 홀더 생성
        return new RecyItemViewHolder(itemView);
    }

    public void onBindViewHolder(RecyclerAdapter.RecyItemViewHolder holder, int position) {
        // 어댑터가 가지고 있는 아이템 리스트에서 position에 맞는 데이터 꺼냄
        WeatherHour curItem = itemList.get(position);

        // 중략...

        // 뷰홀더가 가지고 있는 뷰를 꺼낸 데이터로 업데이트
        holder.skyView.setText(curItem.sky);
    }

    public int getItemCount() {
        // 어댑터가 가지고 있는 리스트의 길이 반환
        return itemList.size();
    }
뷰 홀더 클래스 정의

뷰 홀더는 RecyclerView.ViewHolder 클래스를 상속 받는다. 뷰 홀더는 생성자를 통해 받은 아이템 뷰 객체 중 데이터를 업데이트 시켜야될 뷰 객체들을 프로퍼티로 가지고 있는다.

    static class RecyItemViewHolder extends RecyclerView.ViewHolder {

        // 중략...

        // 뷰홀더 클래스는 프로퍼티로 업데이트 시킬 뷰 객체를 가지고 있다.
        TextView skyView;

        public RecyItemViewHolder(@NonNull @NotNull View itemView) {
            super(itemView);

            // 중략...

            // 업데이트 시킬 뷰 객체 저장
            skyView = itemView.findViewById(R.id.skyView);
        }
    }
mangab-heo commented 3 years ago

리싸이클러 뷰를 이용한 매 시간별 예보

리싸이클러 뷰에 어댑터 및 레이아웃 매니저 연결

메인 액티비티의 레이아웃에 정의된 리싸이클러 뷰에 이전에 정의한 어댑터 객체와 레이아웃 매니저를 연결하는 작업을 해야 한다. 이때 레이아웃 매니저를 통해 리싸이클러 뷰의 상하 방향과 좌우 방향을 설정할 수 있다. 이렇게 리싸이클러 뷰에 대한 설정이 끝나고 난 이후에 데이터가 업데이트 되면 어댑터의 notifyDatasetChanged 메소드를 통해 리싸이클러 뷰를 업데이트 할 수 있다.

// 어댑터 객체를 생성하면서 아이템 객체 리스트를 인자로 넘겨줌
RecyclerAdapter recyclerAdapter = new RecyclerAdapter(weatherData.getWeatherHours());

// 리싸이클러 뷰 객체 참조
recyclerView = findViewById(R.id.recyclerView);
// 리싸이클러 뷰에 어댑터 설정
recyclerView.setAdapter(recyclerAdapter);
// 리싸이클러 뷰에 레이아웃 매니저 설정
recyclerView.setLayoutManager(new LinearLayoutManager(MainActivity.this));
// 업데이트 된 데이터를 리싸이클러 뷰에 적용
recyclerAdapter.notifyDataSetChanged();
mangab-heo commented 3 years ago

위치 정보 획득 및 적용

위치 권한 획득
mangab-heo commented 3 years ago

위치 권한 획득

권한 있는지 확인

위치 정보는 LocationManager가 제공하는 메소드(getLastKnownLocation, requestLocationUpdates)를 통해 얻을 수 있다. 메소드를 호출할 때 인자로 LocationManager.GPS_PROVIDER 또는 LocationManager.NETWORK_PROVIDER를 넘겨줘야 하는데 이때 GPS_PROVIDER와 NETWORK_PROVIDER를 사용하기 위해서는 ACCESS_FINE_LOCATION 권한이 필요하다. GPS_PROVIDER와 NETWORK_PROVIDER 외에도 PASSIVE_PROVIDER가 있고 ACCESS_FINE_LOCATION 외에도 ACCESS_COARSE_LOCATION 권한이 있는데 이 프로젝트에서는 ACCESS_FINE_LOCATION 권한을 통해 GPS_PROVIDER와 NETWORK_PROVIDER를 사용하여 위치 정보를 얻는다.

// LocationManager 참조
LocationManager locationManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE);

// 중략..., 권한 획득하는 로직 필요

// LocationManager를 통해 gps 정보 획득
Location lastKnownLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);

manifest.xml 파일에 ACCESS_FINE_LOCATION 권한을 추가하더라도 getLastKnownLaction이나 requestLocationUpdates를 호출하려하면 컴파일 에러가 발생한다. 이는 ACCESS_FINE_LOCATION 권한의 보호 수준이 dangerous이기 때문에 보호 수준이 normal인 권한처럼 설치시에 자동으로 권한이 부여되지 않기 때문이다. 앱이 보호 수준이 dangerous인 권한을 필요로 하는 메소드를 호출하려면 권한이 있는지 확인하는, 권한이 없다면 권한을 요청하는 코드를 작성해야 한다. 권한이 있는지는 ActivityCompat.checkSelfPermission 메소드의 리턴값이 PackageManager.PERMISSION_GRANTED와 같은지 비교를 통해 확인할 수 있다.

// ACCESS_FINE_LOCATION 권한이 있는지 확인
if (ActivityCompat.checkSelfPermission(MainActivity.this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
    // 권한이 없을 때
    Toast.makeText(MainActivity.this, "권한이 필요합니다.", Toast.LENGTH_LONG).show();
}
else {
    // 권한이 있을 때
    // GPS_PROVIDER를 이용하여 위치 정보 획득
    Location lastKnownLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
    if (lastKnownLocation == null) 
        // GPS_PROVIDER로 위치 정보를 못 얻었을 경우 NETWORK_PROVIDER를 이용
        lastKnownLocation = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER);

    // 중략...
}
mangab-heo commented 3 years ago

위치 권한 획득

권한 없을 시 권한 획득

위험 수준이 dangerous인 권한은 manifest.xml 파일에 추가했더라도 설치 시에 자동으로 부여되지 않고 앱이 실행 중일 때 사용자에게 권한을 요청해야 한다. 권한 요청은 ActivityCompat.requestPermissions 메소드를 통해 할 수 있다. 이때 주의해야 할 점은 이전에 권한 요청을 했을 때 사용자가 '거부 및 다시 묻지 않음'을 선택했다면 requestPermissions 메소드를 호출하더라고 권한 대화상자가 표시되지 않는다는 점이다. '거부 및 다시 묻지 않음' 상태일 때는 권한 대화상자가 아니라 설정 화면에서 사용자가 위치 권한을 추가하도록 해야 한다.

또 주의해야할 점은 '거부 및 다시 묻지 않음' 상태인지 아닌지 확인하는 메소드가 없어서 다른 경우를 하나씩 제거하면서 '거부 및 다시 묻지 않음' 상태인 경우를 찾아야 한다는 점이다. 현재 권한이 없다는 것은 다음 세 가지 경우를 의미한다. 권한 요청을 한 적이 없어서 권한이 없는 경우, 이전에 권한 요청을 했지만 사용자가 '거부'를 선택했던 경우, 이전에 권한 요청을 했을 때 '거부 및 다시 묻지 않음'을 선택했던 경우이다. 먼저, '거부' 상태인지 아닌지 확인하는 메소드를 통해 '거부' 상태인지 아닌지 확인하고 '거부' 상태가 아니라면 처음 권한 요청을 하는 상황인지 '거부 및 다시 묻지 않음' 상태인지 구분한다. 처음 권한 요청을 하는 상황인지 아닌지는 SharedPreferences(간단한 데이터를 파일로 저장하고 불러옴)를 이용해 구분할 수 있다. 이렇게 각각의 경우를 구분하여 각 경우에 맞는 로직을 구현한다.

// 권한이 있는지 확인
if (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.ACCESS_FINE_LOCATION)
    != PackageManager.PERMISSION_GRANTED) { 
        // 권한이 없는 경우, 먼저 '거부' 상태인지 확인
        if (ActivityCompat.shouldShowRequestPermissionRationale(MainActivity.this, Manifest.permission.ACCESS_FINE_LOCATION)) {
            // 거부 상태일 때, SnackBar를 통해 권한이 필요한 이유 설명, "권한승인" 버튼 클릭 시 권한 요청
            Snackbar snackBar = Snackbar.make(findViewById(R.id.layout_main), R.string.suggest_permission_grant, Snackbar.LENGTH_INDEFINITE).setDuration(8000);
            snackBar.setAction("권한승인", v ->
                        // REQUEST_CODE는 임의로 정한 상수, 101 사용
                        ActivityCompat.requestPermissions(MainActivity.this, new String[] {Manifest.permission.ACCESS_FINE_LOCATION}, REQUEST_CODE));
            snackBar.show();
        }
        else {
            // 거부 상태가 아닐 때, 처음 권한 요청을 하는지 확인
            // pref 파일을 열고 "isFirstPermissionCheck" key로 저장된 value 불러옴, 없으면 true 리턴
            SharedPreferences pref = getSharedPreferences("pref", Context.MODE_PRIVATE);
            boolean isFirstCheck = pref.getBoolean("isFirstPermissionCheck", true);
            if (isFirstCheck) {
                // 처음으로 권한 요청 하는 경우
                // "isFirstPermissionCheck" key에 false 저장
                pref.edit().putBoolean("isFirstPermissionCheck", false).apply();
                // 권한 요청
                ActivityCompat.requestPermissions(MainActivity.this, new String[] { Manifest.permission.ACCESS_FINE_LOCATION }, REQUEST_CODE);
            }
            else {
                // "거부 및 다시 묻지 않음" 상태인 경우
                // 스낵바를 통해 "확인" 클릭시 설정으로 이동한다는 설명 후 "확인" 클릭시 설정 화면으로 이동
                SnackBar snackbar = Snackbar.make(findViewById(R.id.layout_main), R.string.suggest_permission_grant_in_setting, Snackbar.LENGTH_LONG).setDuration(8000);
                snackBar.setAction("확인", v -> {
                        Intent intent = new Intent();
                        intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
                        Uri uri = Uri.fromParts("package", getPackageName(), null);
                        intent.setData(uri);
                        startActivity(intent);
                });
                snackBar.show();
            }
        }
}

requestPermissions 메소드를 통해 권한 대화상자를 열고 사용자의 응답을 받으면 응답은 onRequestPermissionsResult 메소드의 grantResults로 전달된다. onRequestPermissionsResult는 requestPermissions 메소드의 콜백 함수인데 requestPermissions의 타겟 액티비티에서 오버라이딩하여 구현해야 한다. 이 앱에서는 권한을 받으면 위치 정보를 받아와 api를 호출하고 권한을 못 받으면 권한이 필요하다는 Toast를 보여준다.

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull @NotNull String[] permissions, @NonNull @NotNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        // requestPermissions를 호출할 때 지정한 requestCode를 통해 어떤 requestPermissions 메소드에 대한 콜백인지 구분한다.
        if (requestCode == REQUEST_CODE) {
            if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                // 사용자가 권한을 허용했을 경우 위치 정보 획득 후 api 호출
                Toast.makeText(MainActivity.this, "권한 획득", Toast.LENGTH_LONG).show();

                // 중략...

                // api 호출
                getFcstAndUpdateView(baseDate, baseTime, x, y);
             }
             else {
                // 권한을 얻지 못 했을 경우 권한이 필요하다는 Toast 띄움
                Toast.makeText(MainActivity.this, "권한이 필요합니다.", Toast.LENGTH_LONG).show();
             }
mangab-heo commented 3 years ago

위치 정보 획득 및 적용

위치 정보 획득

ACCESS_FINE_LOCATION 이나 ACCESS_NETWORK_LOCATION에 대한 권한을 얻었으면 LocationManger의 getLastKnownLocation 메소드나 requestLocationUpdates 메소드를 통해 위치 정보를 얻을 수 있다. getLastKnownLocation은 메소드를 실행할 때 한 번 위치 정보를 얻는 것이고 requestLocationUpdates는 LocationListener를 인자로 전달하여 인자로 같이 전달한 시간, 거리 만큼의 변화가 생길 때마다 위치 정보를 얻을 수 있다. 이 앱에서는 업데이트 버튼을 누를 때마다 위치 정보를 얻어서 api를 호출하려 하기 때문에 getLastKnownLocation 메소드를 사용한다. getLastKnownLocation의 리턴 자료형은 Location 클래스이고 Location 클래스의 getLongitude와 getLatitude 메소드를 통해 위도와 경도를 알 수 있다.

위치 정보 변환 및 사용

getLastKnownLoaction 메소드를 통해 얻은 위도와 경도를 기상청 api가 사용하는 격자 x좌표와 y좌표로 변환하는 작업이 필요하다. api 문서에 동네예보 지점 좌표(x, y)위치와 위경도 간의 전환 C언어 예제가 있지만 검색을 통해 기상청 격자 <-> 위경도 변환, 이 링크의 코드를 사용했다.

private WeatherGrid.LatXLngY getGridLocation() {
    // GPS_PROVIDER를 이용한 위치 정보 획득
    Location lastKnownLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
    WeatherGrid.LatXLngY latXLngY = nulll;

    if (lastKnownLocation == null) 
        // GPS_PROVIDER로 위치 정보를 얻지 못 했을 경우 NETWORK_PROVIDER를 이용하여 위치 정보 획득
        lastKnownLocation = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER);

    if (lastKnownLocation != null) {
        // 위도와 경도 정보 획득
        double lng = lastKnownLocation.getLongitude();
        double lat = lastKnownLocation.getLatitude();

        // 위경도를 동네예보 지점 좌표(x,y)위치로 전환
        latXLngY = convertGRID_GPS(TO_GRID, lat, lng);
    }
    return latXLngY;
}
mangab-heo commented 3 years ago

위치 정보 획득 및 적용

현재 시간 적용

동네예보 조회 서비스 api는 예보 발표를 3시간 간격으로 하루에 8번(02:00, 05:00, 08:00, 11:00, 14:00, 17:00, 20:00, 23:00) 하는데 예보 발표 후 10분 뒤부터 api 호출이 가능하므로 예를 들어 08시에 발표한 예보는 08시 10분 이후에 호출하여야 한다. 이를 고려하여 현재 시각에 가장 가까운 시간에 발표한 예보를 받아오는 로직은 다음과 같다. 현재 시각에서 3시간으로 나눈 나머지 시간이 두시간 10분 보다 크면 현재 시각에서 나머지 시간을 뺀 뒤 두 시간을 더하고 만약에 나머지 시간이 두시간 10분보다 작으면 나머지 시간을 뺸 뒤 한시간을 더 뺸다. 이때 00:00 ~ 02:10까지는 전날 23시에 발표한 예보를 불러와야하므로 이 경우만 따로 처리한다. 현재 날짜와 시각을 구할 때는 먼저, SimpleDateFormat 클래스를 이용해 원하는 형식의 날짜와 시간 표기법을 정하고 그 다음에, System.currentTimeMillis 메소드의 리턴값을 Date 클래스의 생성자의 인자로 넘겨준 뒤 SimpleDateFormat의 format 메서드 호출함으로써 구할 수 있다.

    private String[] findBaseDateTime() {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd HHmm", Locale.KOREA);
        String curDateTimeStr = sdf.format(new Date(System.currentTimeMillis()));
        String[] curDateTime = curDateTimeStr.split(" ");

        String curTime = curDateTime[1];
        String baseDate = curDateTime[0];
        String baseTime;

        int quotient = Integer.parseInt(curTime) / 300;
        int remain = Integer.parseInt(curTime) % 300;

        if (quotient == 0 && remain <= 210) {
            // 00시 00분 ~ 02시 10분
            // 전날 23시로 호출
            String[] yesterDateTime = sdf.format(new Date(System.currentTimeMillis() - 1000*60*60*24*-1))
                    .split(" ");
            baseDate = yesterDateTime[0];
            baseTime = "2300";
        }
        else {
            // 02시 11분 ~ 23시 59분
            if (remain <= 210) {
                // - remain - 100
                baseTime = String.valueOf(Integer.parseInt(curTime) - remain - 100);
            }
            else {
                // - remain + 200
                baseTime = String.valueOf(Integer.parseInt(curTime) - remain + 200);
            }
        }

        if (baseTime.length() < 4) {
            baseTime = "0" + baseTime;
        }

        return new String[] { baseDate, baseTime };
    }
mangab-heo commented 3 years ago

초단기 예보 api 사용

mangab-heo commented 3 years ago

초단기 예보 api 사용

비동기 통신 및 Rxjava

초단기 예보 api는 3일 간의 예보를 하는 동네 예보 api와 달리 6시간 동안의 예보를 한다. 예를 들어, 현재 시각이 오전 8시 반이면 동네예보 api는 오전 9시부터 모레 자정(글피 0시)까지 매 시 예보(64개)를 제공하고 초단기 예보 api는 오전 8시부터 오후 1시까지 매 시 예보(6개)를 제공한다. 동네 예보 api보다 초단기 예보 api가 제공하는 예보가 더 정확하므로 오전 9시부터 오후 1시까지의 예보는 초단기 예보 api를 사용하고 그 이후 예보는 동네 예보 api를 사용하려고 한다(오전 8시 예보 정보는 현재 날씨 정보를 보여주는 텍스트 뷰로 따로 보여준다).

이를 구현할 때 기존에 있었던 Retrofit2의 onResponse 콜백을 이용한다면 생각해볼 수 있는 첫 번째 방법은 한 api 호출의 onResponse 콜백에서 다른 api를 호출하는 것이다. 예를 들어, 동네 예보 api의 onResponse 콜백에서 초단기 예보 api를 호출하는 것이다. 이렇게 하면 기존의 방법을 그대로 사용하기도 하고 데이터의 흐름도 명확해서 구현하기 쉽다는 장점이 있다. 그러나 한 api의 응답이 와야지 다음 api를 호출하는 것이기 때문에 한 api의 응답 여부와 상관없이 다른 api를 요청하는 방법보다 느리고 콜백 함수안에 콜백이 있기에 코드가 깔끔하지 못하다는 단점이 있다.

두 번째 방법은 onResponse 콜백에서 if, else를 통해 다른 api가 먼저 도착했을 때와 아닐 때를 구분하여 구현하는 것이다. 혹은 몇 개의 응답이 왔는지 개수를 세서 마지막 응답이 도착했을 때를 구분하여 구현하는 것이다. 그러나 이렇게 하면 코드가 중복되고 또 onResponse 콜백 코드를 서로 다른 쓰레드에서 처리한다면 count 관련된 부분에 락을 걸어야 안전할 것 같다.

@Override
public void onResponse(@NonNull Call<FcstResult> call, @NonNull Response<FcstResult> response) {
    // 응답 데이터 파싱
    // 중략...

    // 응답 개수 1 증가
    this.count += 1;
    if (this.count == 2) {
        // 마지막 응답 처리 로직
    }
}

두 api 호출은 동시에 하되 두 응답이 전부 왔을 때 한번에 처리하는 방법을 검색해보니 Rxjava의 zip이 있었다. Rxjava는 옵저버 패턴으로 비동기 처리를 하는 라이브러리인데 Observable과 Observer라는 클래스가 있어서 옵저버블이 어떤 아이템을 방출하면 옵저버가 미리 정의된 콜백을 수행하는 구조이다. 조금 더 구체적으로 Observable은 create, just , defer, fromArray, fromIterable 등의 메소드로 아이템을 방출하고 map이나 flatMap 등의 메소드로 중간에 방출한 아이템을 변형하고 subscribe 메소드로 옵저버를 설정한다. subscribe 메소드의 인자로 들어갈 옵저버는 onNext, onError, onComplete 콜백을 구현해야 하고 이 메소드는 옵저버블이 아이템을 방출하면 수행된다.

// create 예제
// create는 subscriber가 onNext, onComplete, onError 함수를 언제 호출할지를 직접 구현한다.
Observable.create(subscriber -> {
    subscriber.onNext("Hello, world!");
    subscriber.onComplete();
})
    // onNext, onComplete, onError를 구현한 subscriber를 생성해서 subscribe 메소드의 인자로 넘겨줘도 되고 따로 따로 구현해서 인자로 넘겨줘도 된다.
    // 인자가 하나면 onNext, 두 개면 onNext와 onError, 세 개면 onNext, onComplete, onError를 인자로 받는다
    .subscribe(s -> System.out.println(s) , throwable -> { /* Error handling */ });

)
// just 예제
// just는 아이템을 발행하고 종료한다. 
Observable.just("Hello, world!")
    // map을 이용해 방출한 아이템을 변형할 수 있다.
    // flatMap을 이용하면 Observable로 변형할 수 있어 연속적인 비동기 처리가 가능하다.
    .map(s -> s + " -Dan")
    .subscribe(s -> System.out.println(s));
mangab-heo commented 3 years ago

초단기 예보 api 사용

Rxjava zip

zip은 여러 옵저버블을 결합할 때 사용하는데 여러 옵저버블이 다 처리될때까지 기다렸다가 다 처리되면 방출된 아이템들을 받아서 미리 정의한 로직을 수행한다. 이 프로젝트에서 구현하고자 하는 것이 초단기 예보 api와 동네 예보 api를 호출하고 두 api의 응답이 다 왔을 때 한번에 처리하는 것이므로 zip 메소드를 이용하여 구현한다. Retrofit과 Rxjava를 같이 사용하는 방법은 다음과 같은데 Retrofit 객체를 만들 때 Rxjava3CallAdapterFactory.create()를 인자로 addCallAdapterFactory 메서드를 호출하고 요청 함수를 구현하는 인터페이스에서 리턴 타입을 Call 클래스 대신에 Observable 클래스로 바꾸는 것이다. Rxjava3CallAdapterFactory를 사용하기 위해서는 retrofit2:adapter-rxjava 라이브러리를 추가해야 한다.

public class RetrofitClient {
    // 중략...

    private static Retrofit getInstance() {
        Gson gson = new GsonBuilder()
                .setLenient()
                .create();

        return new Retrofit.Builder()
                .baseUrl(BASE_URL)
                .addConverterFactory(GsonConverterFactory.create(gson))
                 // RxJava3CallAdpaterFactory 설정
                .addCallAdapterFactory(RxJava3CallAdapterFactory.create())
                .build();
    }
}
public interface FcstService {
    @GET("getVilageFcst")
    // 리턴 타입을 Observable 클래스로 변경
    Observable<FcstResult> getVilageFcst(@Query("serviceKey") String serviceKey,
                                         @Query("pageNo") String pageNo,
                                         @Query("numOfRows") String numOfRows,
                                         @Query("dataType") String dataType,
                                         @Query("base_date") String base_date,
                                         @Query("base_time") String base_time,
                                         @Query("nx") String nx,
                                         @Query("ny") String ny);
    // 중략...
}

Observable.zip 메소드의 인자로 두 개를 넘겨주는데 하나는 api 요청 함수가 리턴한 Observable 객체들의 List이고 나머지 하나는 옵저버블 객체들이 아이템을 다 방출한 후 수행될 로직을 구현한 콜백 함수이다. zip 메소드에 이어서 subscribe 메소드를 바로 호출한다. 이때 subscribe 메소드에 인자로 zip 메소드가 방출한 아이템을 처리하는 로직을 넘겨준다.

        // 동네 예보 조회 api의 응답을 방출하는 Observable 객체
        Observable<FcstResult> getVilageFcst = RetrofitClient.getFcstService().getVilageFcst(
                FcstClientConstants.SERVICE_KEY, FcstClientConstants.PAGE_NO, FcstClientConstants.NUM_OF_ROWS, FcstClientConstants.DATA_TYPE,
                baseDateTimes[0], baseDateTimes[1], nx, ny);
        // 초단기 예보 api의 응답을 방출하는 Observable 객체
        Observable<FcstResult> getUltraSrtFcst = RetrofitClient.getFcstService().getUltraSrtFcst(
                FcstClientConstants.SERVICE_KEY, FcstClientConstants.PAGE_NO, FcstClientConstants.NUM_OF_ROWS, FcstClientConstants.DATA_TYPE,
                baseDateTimes[2], baseDateTimes[3], nx, ny);

        // Observable 객체들을 zip 메소드의 인자로 넘겨주기 위한 List 생성
        List<Observable<?>> requests = new ArrayList<>();
        requests.add(getVilageFcst);
        requests.add(getUltraSrtFcst);

        Observable.zip(requests, objects -> {
            // 중략..., 옵저버블들이 방출한 아이템들을 받아서 처리하는 로직

            return weatherDataVilage;
        })
                // subscribeOn 메소드를 통해 zip 옵저버블이 아이템을 방출하는 작업을 수행할 쓰레드를 지정
                .subscribeOn(Schedulers.newThread())
                // observeOn 메소드를 통해 zip 옵저버블이 방출한 아이템을 처리하는 작업을 수행할 쓰레드를 지정
                // 뷰를 갱신해야 하므로 메인 쓰레드를 지정한다.
                .observeOn(AndroidSchedulers.mainThread())
                .compose(bindToLifecycle())
                .subscribe(weatherData -> {
                // 중략..., zip 메소드가 방출하는 weatherDataVilage를 받아서 처리하는 로직

                }, throwable -> Toast.makeText(MainActivity.this, throwable.toString(), Toast.LENGTH_LONG).show());

zip 매소드를 호출한 뒤 subscribeOn과 observeOn 메소드를 통해 각각 아이템을 방출하는 작업을 수행할 쓰레드와 방출된 아이템을 처리하는 쓰레드를 지정한 뒤 compose 메소드를 호출했다. compose(bindToLifecycle())를 호출하는 이유는 액티비티가 종료되면 옵저버블 객체가 자동으로 unsubscibe 하도록 하기 위함이다. 옵저버블 객체가 아이템을 방출하고 종료되기 전에 액티비티가 먼저 종료되면 옵저버블 객체가 액티비티를 참조하고 있어 액티비티가 메모리에서 사라지지 못해 메모리 누수가 발생한다. bindToLifecycle 함수를 사용하기 위해서는 rxlifecycle과 rxlifecycle-components 라이브러리를 추가하고 메인 액티비티가 RxAppCompatActivity를 상속 받도록 해야 한다.

mangab-heo commented 3 years ago

초단기 예보 api 사용

WeatherData 클래스 수정 및 데이터 뷰에 노출

초단기 예보와 동네 예보 조회, 이렇게 두 api를 이용하여 메인 액티비티의 리싸이클러 뷰와 텍스트 뷰의 내용을 업데이트 하는데 하나의 WeatherData 객체를 이용하여 뷰의 내용을 업데이트하도록 WeatherData 클래스에 텍스트 뷰에 들어갈 정보를 담은 필드를 두 개를 추가했다. Observable.zip 메소드에서 두 Observable 객체가 방출하는 아이템을 취합하여 하나의 WeatherData 객체를 만든 뒤 subscribe 메소드에서 이 WeatherData 객체를 이용하여 리싸이클러 뷰와 텍스트 뷰의 내용을 업데이트한다.

mangab-heo commented 3 years ago

미세먼지 조회 api 사용

mangab-heo commented 3 years ago

측정소 위경도 구하기

현재 위경도와 가장 가까운 측정소 구하는 방법

측정소별 실시간 측정정보 조회 api를 통해 현재 미세먼지 정보를 얻을 수 있다. 이 api는 어떤 측정소의 측정값을 사용할 것인지를 파라미터로 넘겨줘야 해서 api를 호출하기 전에 현재 위치와 가장 가까운 관측소를 찾는 작업이 필요하다. 현재 위치와 가장 가까운 측정소를 알려주는 api도 있는데 이는 위경도가 아니라 TM 좌표를 파라미터로 요구한다. TM좌표를 얻는 api도 있는데 TM 기준좌표 조회라는 읍면동 이름을 파라미터로 넘기면 해당 읍면동의 TM 기준 좌표를 알려주는 api가 있다. 요약하자면, 현재 위치가 속한 읍면동 이름을 알아낸 후 3번의 api 호출(TM 좌표 얻기, TM 좌표로 가장 가까운 측정소 이름 얻기, 측정소 이름으로 미세먼지 정보 얻기)을 통해 현재 위치의 미세먼지 정보를 얻을 수 있다.

이 방법은 현재 위경도랑 가장 가까운 측정소를 찾는 과정이 복잡하므로 더 간단하게 측정소를 찾는 다른 방법을 생각해보았다. 만약 모든 측정소의 위경도 정보가 앱 내에 저장되어 있다면 한 번의 반복을 통해 가장 가까운 측정소를 찾을 수 있다. 그렇기에 모든 측정소의 위경도를 미리 구해서 앱에 따로 저장해놓는 게 가능하다면 이 방법이 더 나을 것이다(도시 대기 측정소 전체 개수가 495개 이다). 에어코리아사이트에서 도시대기 측정소 전체의 이름과 주소가 들어있는 엑셀파일을 제공하고 있었다. 이 엑셀 파일을 읽어서 측정소 이름을 요청 파라미터로 해당 측정소의 위경도를 알아낸 뒤 이를 텍스트 파일로 저장하고 미리 프로젝트에 포함시켜 놓는다면 앱을 실행시킬 때 이 파일을 한 번 읽는 것으로 현재 위경도랑 가장 가까운 측정소를 알 수 있을 것이다. 그래서 앱 실행 시 실행되는 파일이 아니라 앱에 에셋으로 포함되어 있을 텍스트 파일을 만드는 스크립트를 작성했다.

측정소 위경도를 얻는 api를 호출하는 스크립트 작성

에어코리아사이트에서 제공하는 파일은 엑셀 파일이다. 이 파일을 파싱하여 측정소 이름을 구하고 이 측정소 이름을 요청 파라미터로하여 측정소의 위경도를 텍스트 파일로 저장하여야 한다. 엑셀 파일을 파싱하는 것보다 csv파일을 파싱하는 게 더 편해서 스크립트를 작성하기 전에 먼저 이 사이트에서 xls파일을 csv파일로 바꿨다.

스크립트 파일은 csv 파서를 이용하여 측정소 이름을 구하여 해당 측정소의 위경도를 알려주는 api를 호출하고 응답이 성공하면 측정소 이름과 위경도를 텍스트 파일에 추가하고 실패하면 실패한 url을 다른 텍스트 파일에 저장했다. 이때 오픈 api가 요청을 연속으로하면 응답이 실패할 때가 있기 때문에 요청을 한 번 보낸 뒤 3초 기다렸다가 다시 보내도록 하였다. 요청이 실패하면 요청 큐에 추가하여 큐가 빌때까지 요청을 반복하도록 하지 않고 따로 파일로 저장한 이유는 오픈 api가 일일 트래픽을 500으로 제한했기 때문이다. 그래서 실패한 url을 읽어서 api 호출을 하는 스크립트도 작성했다.

응답 실패한 요청을 다시 요청하는 스크립트 작성

응답 실패한 요청을 다시 요청하는 스크립트는 파일을 한 줄씩 읽으면서 요청을 보내는데 실패하면 다시 그 파일에 추가하도록 했고 요청 실패 개수를 세서 요청 실패 개수가 300개 이상이면 프로세스를 종료하도록 하였다.

mangab-heo commented 3 years ago

가까운 측정소 찾기

에셋 폴더로부터 텍스트 파일 읽기

안드로이드 프로젝트에 에셋을 추가하려면 src/main/assets/ 폴더에 추가하고자 하는 파일을 복사하면 된다. 모든 측정소의 이름과 위경도를 저장해놓은 파일을 위 경로에 복사한다. 이때 assets 폴더가 없으면 만들고 복사한다.

미세먼지 api를 호출할 때마다 현재 위치와 가장 가까운 측정소를 찾아야 하므로 onCreate 때 모든 측정소의 위경도 정보를 불러와서 메인 액티비티에서 가지고 있도록 한다. 에셋 폴더에 있는 파일의 인풋 스트림은 AssetManager 객체의 open 메소드를 통해 얻을 수 있는데 AssetManager 객체는 getResources 함수가 리턴하는 Resources 객체의 getAssets 메소드를 통해 얻을 수 있다. 파일에는 측정소 이름, 위도, 경도가 한 줄씩 적혀있어서 BufferedReader 클래스의 readline 메소드를 이용하여 파일을 한 줄씩 읽고 파싱한다. BufferedReader 객체를 생성할 때는 InputStreamReader 객체를 인자로 넘겨주고 InputStreamReader 객체를 생성할 때는 AssetManager 객체의 open 메소드를 통해 얻은 스트림을 인자로 넘겨준다. AssetManager 객체의 open 메소드를 통해 얻은 스트림은 byte로 읽기 떄문에 문자열로 읽고 싶으면 InputStreamReader를 사용해야 하고 버퍼를 따로 지정할 필요없이 한 줄씩 읽으려면 BufferedReader를 이용해야 한다.

AssetManager assetManager = getResources().getAssets();
try {
    // 파일을 open 하려면 try catch로 감싸줘야 한다.
    InputStream inputStream = assetManager.open("stationLocation.txt");
    BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
    String stationLine;

    while ((stationLine = bufferedReader.readLine()) != null) {
        // 중략...

    }

    inputStream.close();
    bufferedReader.close();
} catch (IOException e) {
    e.printStackTrace();
}

현재 위치와 가장 가까운 측정소 찾기

LocationManager를 통해 현재 위경도를 얻었으면 메인 액티비티가 가지고 있는 모든 측정소의 위경도와 거리를 구하고 그중 제일 가까운 측정소를 찾는다. 위경도끼리 거리를 구하는 함수는 이 사이트를 참고했다.

// 중략...

double latitude = gridLocation.lat;
double longitude = gridLocation.lng;

String stationName;
double minDist = Double.MAX_VALUE;
/

// 메인 액티비티에 모든 측정소의 위경도를 담고 있는 stationLocations 필드가 있음
for (StationLocation stationLocation : stationLocations) {
    double dist = stationLocation.getDistance(latitude, longitude);
    if (dist < minDist) {
        stationName = stationLocation.name;
        minDist = dist;
    }
}
// 제일 가까운 측정소 이름을 인자로 api 호출
getDnstyAndUpdateView(stationName);
mangab-heo commented 3 years ago

미세먼지 api 호출

미세먼지 api도 다른 api처럼 레트로핏을 이용하여 통신을 하고 RxJava를 이용하여 비동기 처리를 한다. 레트로핏 객체가 리턴한 Observable 객체의 map 메소드를 이용하여 응답 받은 데이터를 뷰에서 활용할 수 있는 형태로 바꿔주고 subscribe를 통해 메인 쓰레드에서 미세먼지 관련된 텍스트 뷰를 업데이트하도록 한다.

// 레트로핏을 이용한 옵저버블 객체 생성
Observable<ArpltnResult> getDnsty = getArpltnService().getDnsty(
        // 중략...

        , stationName
);

// 옵저버블 객체의 map 메소드 사용
getDnsty.map(arpltnResult -> {
    // arpltnResult는 응답 데이터가 들어가 있음.

    // PmInfo 클래스에 뷰에 그릴 미세먼지 정보를 담음.
    PmInfo pmInfo = new PmInfo();

    // 중략...

    return pmInfo;
})
        .subscribeOn(Schedulers.newThread())
        .observeOn(AndroidSchedulers.mainThread())
        .compose(bindToLifecycle())
        .subscribe(pmInfo -> {
            // map 메소드가 리턴한 PmInfo 객체를 받아서 뷰에 그림 
            if (pmInfo.pmStrs[0] != null) {
                TextView pm10TextView = findViewById(R.id.pm10_text_view);
                TextView pm10UnitView = findViewById(R.id.pm10_unit_view);

                // 중략...

                pm25TextView.setText(pm25Text);
            }
        }, throwable -> Toast.makeText(MainActivity.this, throwable.toString(), Toast.LENGTH_LONG).show());
}
mangab-heo commented 3 years ago

리팩토링 및 에러 처리

mangab-heo commented 3 years ago

리팩토링

리팩토링을 크게 두 가지 관점으로 했는데 첫 번째는 코드를 더욱 세부적으로 함수화하고 클래스를 나누어 로직을 알아보기 쉽게 구성하는 것이고 두 번째는 api 통신을 하고 받은 데이터를 파싱하고 뷰를 업데이트하는 코드를 메인 액티비티 클래스에서 데이터 클래스로 옮기는 것이다.

첫 번째로 코드를 함수화하여 로직을 알아보기 쉽게 구성하는 것은 크게 어려움 없이 할 수 있었는데 메인 액티비티의 onCreate 콜백함수에서 수행하는 작업은 먼저 액션바 관련 작업, 에셋 파일을 읽는 작업, 버튼에 리스너를 다는 작업, api 호출하여 뷰를 업데이트하는 작업 이렇게 있었고 리팩토링 하기 전에는 이것이 함수별로 구분되어져 있지 않았어서 이를 각자의 기능에 맞게 함수를 나누고 의미적으로 하나의 클래스로 묶을 수 있으면 따로 클래스를 만들었다. 함수를 만들 때 기능별로 수행되어야 하는 순서도 고려해야 하는데 예를 들어서 에셋 파일을 읽는 작업은 api를 호출하여 뷰를 업데이트 하는 작업 이전에 이루어져야 하고 이를 고려하여 api를 호출하여 뷰를 업데이트하는 함수와 에셋 파일을 읽는 작업을 포함한 새로운 함수를 만들어서 메인 액티비티에서 이 함수를 호출하도록 만들었다. 또 에셋파일을 읽는 작업과 위치 권한을 확인하고 얻는 작업은 전부 위치 관련 정보를 다루기 때문에 LocationUtil 클래스를 만들어서 따로 분리했다.

public class MainActivity extends RxAppCompatActivity {
    @RequiresApi(api = Build.VERSION_CODES.N)
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

       // 액션바 관련 작업
        setActionBar();

       // 버튼에 리스너를 다는 작업
        setImageButtonListener();

        // api호출 하기 위한 사전 작업을 하고 api를 호출한 뒤 view를 업데이트하는 작업
        drawMainView();
    }

    @RequiresApi(api = Build.VERSION_CODES.N)
    private void drawMainView() {
        // 위치 권한을 확인
        if (LocationUtil.isLocationPermissionGranted(MainActivity.this)) {
            try {
                // 현재 위치 획득
                WeatherGrid.LatXLngY gridLocation = LocationUtil.getGridLocation(getApplicationContext());
                // 현재 위치와 가장 가까운 관측소 획득, 이때 필요에 따라 에셋 파일을 읽는다.
                String stationName = LocationUtil.getClosestStation(getApplicationContext(), gridLocation);

                // 현재 위치와 관측소 정보를 가지고 api 호출 후 뷰 업데이트
                callApiAndUpdateView(gridLocation, stationName);
            } catch (Exception exception) {
                Toast.makeText(MainActivity.this, exception.getMessage(), Toast.LENGTH_LONG).show();
            }
        }
    }

두 번째로 api 통신을 하고 받은 데이터를 파싱하고 뷰를 업데이트하는 코드를 리팩토링하는 것인데 라이브러리 특성상 rxjava의 zip함수 내부에서 데이터를 파싱하는 작업을 해야 했고 또 subscribe함수 내부에서 뷰를 업데이트하는 작업을 해야 했었다. 이때 데이터를 파싱하는 작업과 그 데이터로 뷰를 업데이트하는 작업은 api 통신을 통해 받은 데이터마다 해야하는 작업이어서 이 작업을 하는 코드들을 각 데이터 클래스 쪽으로 나눠줄 필요가 있었다.

    @RequiresApi(api = Build.VERSION_CODES.N)
    // api 호출을 하고 받은 데이터를 파싱해서 뷰를 업데이트하는 함수
    private void callApiAndUpdateView(WeatherGrid.LatXLngY gridLocation, String stationName) {
        // 옵저버블 리스트을 리턴하는 함수 이때 각 옵저버블은 파싱된 데이터를 리턴한다.
        List<Observable<?>> requests = getObservables(gridLocation, stationName);

        // zip 함수의 콜백함수가 인자로 파싱된 데이터를 받는다.
        Observable.zip(requests, parsedData -> {
            // 파싱한 데이터를 뷰를 업데이트하기 위한 데이터로 바꾸는 작업
            List<ViewData> viewDataList = new ArrayList<>();
            List<WeatherData> weatherDataList = new ArrayList<>();

            for (Object data : parsedData) {
                if (data == null) continue;
                if (data instanceof WeatherData)
                    weatherDataList.add((WeatherData) data);
                else if (data instanceof ViewData) viewDataList.add((ViewData) data);
            }
            viewDataList.add(combineWeatherData(weatherDataList));

            // 뷰를 업데이트하는데 필요한 데이터로 리턴
            return viewDataList;
        })
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
                .compose(bindToLifecycle())
                .subscribe(viewDataList -> {
                    // 각각의 데이터가 메인 액티비티의 뷰를 업데이트 함
                    for (ViewData viewData : viewDataList)
                        viewData.updateView(MainActivity.this);
                }, throwable -> Toast.makeText(MainActivity.this, "날씨 정보를 불러오지 못 했습니다.", Toast.LENGTH_LONG).show());
    }

    @NotNull
    private List<Observable<?>> getObservables(WeatherGrid.LatXLngY gridLocation, String stationName) {

        // 중략...

        List<Observable<?>> requests = new ArrayList<>();

        if (!stationName.equals("")) {
            Observable<PmData> getDnsty = getArpltnService().getDnsty(
                    ArpltnClientConstants.SERVICE_KEY, ArpltnClientConstants.RETURN_TYPE,
                    ArpltnClientConstants.NUM_OF_ROWS, ArpltnClientConstants.PAGE_NO,
                    ArpltnClientConstants.DATA_TERM, ArpltnClientConstants.VER,
                    stationName)
                    // 받은 데이터를 파싱하는 작업, 각각의 옵저버블에 전부 적용한다.
                    .map(ArpltnResult::toAppData);
            requests.add(getDnsty);
        } else Toast.makeText(MainActivity.this, "미세먼지 관측소 정보를 찾지 못했습니다.", Toast.LENGTH_LONG).show();

        Observable<WeatherData> getVillageFcst = RetrofitClient.getFcstService().getVilageFcst(
                FcstClientConstants.SERVICE_KEY, FcstClientConstants.PAGE_NO, FcstClientConstants.NUM_OF_ROWS, FcstClientConstants.DATA_TYPE,
                baseDateTimeVil[0], baseDateTimeVil[1], x, y)
                  // 받은 데이터를 파싱하는 작업
                .map(FcstResult::toAppData);
        requests.add(getVillageFcst);

        Observable<WeatherData> getUltraSrtFcst = RetrofitClient.getFcstService().getUltraSrtFcst(
                FcstClientConstants.SERVICE_KEY, FcstClientConstants.PAGE_NO, FcstClientConstants.NUM_OF_ROWS, FcstClientConstants.DATA_TYPE,
                baseDateTimeSrt[0], baseDateTimeSrt[1], x, y)
                  // 받은 데이터를 파싱하는 작업
                .map(FcstResult::toAppData);
        requests.add(getUltraSrtFcst);

        return requests;
    }
mangab-heo commented 3 years ago

에러처리

에러 처리는 위치 권한을 얻지 못 했을 때, 위치 정보를 얻지 못 했을 때, 에셋 파일을 읽는데 실패하였을 때, API 요청을 실패했을 때를 나눠서 처리하였다. API 통신을 하여 받은 데이터로 뷰를 업데이트하는 로직은 다음과 같다
weather-app error handling

mangab-heo commented 2 years ago

위젯 만들기

mangab-heo commented 2 years ago

위젯 레이아웃 및 프로바이더 클래스 생성

위젯 레이아웃 정의

위젯은 현재 날씨 상태, 현재 온도, 현재 풍속, 현재 시각, 새로 고침 버튼으로 이루어진 상단과 모레까지의 한 시간 단위 예보를 보여주는 리스트 뷰로 이루어져 있다. 리스트 뷰의 아이템 레이아웃은 리싸이클러 뷰의 아이템 레이아웃을 사용한다.

weather-app widget layout     weather-app widget

프로바이더 클래스 생성

프로바이더 클래스는 브로드캐스트 리시버 클래스를 상속 받은 클래스이다. 따라서 매니페스트 파일에 \ 요소를 추가해야하는데 이때 android.appwidget.action.APPWIDGET_UPDATE 액션을 받을 수 있도록 \ 요소를 설정한다. 또 \ 요소를 추가하여 위젯의 크기와 미리 보기 이미지 등의 정보가 정의된 AppWidgetProviderInfo 리소스를 설정한다.

public class WeatherWidget extends AppWidgetProvider {
    // 중략...
}
<!-- AndroidManifest.xml -->
<receiver android:name=".WeatherWidget">
    <intent-filter>
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
    </intent-filter>

    <!-- AppWidgetProviderInfo 리소스 설정 -->
    <meta-data
        android:name="android.appwidget.provider"
        android:resource="@xml/weather_widget_info" />
</receiver>

<!-- weather_widget_info.xml -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
   <!-- 앞서 정의한 위젯 레이아웃 설정 -->
    android:initialLayout="@layout/weather_widget"
   <!-- 중략... -->
/>
mangab-heo commented 2 years ago

리스트 뷰 적용

리모트 뷰 서비스 클래스와 리모트 뷰 팩토리 클래스 구현

위젯에서 리스트 뷰를 사용하기 위해서는 리모트 뷰 서비스와 리모트 뷰 팩토리가 필요하다. 리모트 뷰 서비스는 리모트 뷰 팩토리를 생성하는 역할을 하고 리모트 뷰 팩토리는 어댑터와 같은 역할을 하여 onCreate, getViewAt, onDataSetChanged과 같은 메소드를 오버라이딩한다.

public class WeatherRemoteViewsService extends RemoteViewsService {
    @Override
    public RemoteViewsFactory onGetViewFactory(Intent intent) {
        // 리모트 뷰 팩토리 클래서 생성하여 리턴
        return new WeatherRemoteViewsFactory(this.getApplicationContext());
    }
}

class WeatherRemoteViewsFactory implements RemoteViewsService.RemoteViewsFactory {
    public Context context;
    public List<WeatherHour> itemList;

    WeatherRemoteViewsFactory(Context context) { this.context = context; }

     // 중략...
    @Override
    public RemoteViews getViewAt(int position) {
        // getViewAt 메소드에서 아이템 뷰에 해당하는 리모튜 뷰를 생성하여 리턴한다. 
        RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.recycler_item);

        // 중략...
        return remoteViews;
    }
    // 중략...
}
리스트 뷰와 리모트 뷰 서비스 연결

위젯의 리스트뷰가 이 팩토리 클래스를 사용하기 위해서는 setRemoteAdapter()를 호출해야한다. 이때 인자로 이 서비스 클래스를 가리키는 인텐트를 넘겨준다.

        // 위젯 레이아웃의 리모트 뷰 생성
        RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.weather_widget);

        // 중략...

        // 리모트 뷰 서비스를 구현한 클래스를 가리키는 인텐트 생성
        Intent serviceIntent = new Intent(context, WeatherRemoteViewsService.class);
        serviceIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
        serviceIntent.setData(Uri.parse(serviceIntent.toUri(Intent.URI_INTENT_SCHEME)));

        // 위젯의 리스트뷰에 setRemoteAdapter 메소드를 통해 해당 리모트 뷰 서비스를 어댑터로 설정
        views.setRemoteAdapter(R.id.widget_list_view, serviceIntent);

        // 앱 위젯의 리스트 뷰의 팩토리의 onDataSetChanged 콜백 함수를 실행시킴, 팩토리가 최초 생성될 때 onDataSetChanged가 자동으로 불리고 최초 생성 이후 데이터만 바꾸고 싶을 때 사용한다.
        appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, R.id.widget_list_view);

        // 앱 위젯 매니저에게 이 위젯을 업데이트하라고 함
        appWidgetManager.updateAppWidget(appWidgetId, views);
mangab-heo commented 2 years ago

새로고침 버튼 적용

새로고침 버튼은 이미지뷰로 제공되는데 이 이미지 뷰는 RemoteViews에 포함되어 있기에 클릭 했을 시 수행하고자 하는 행동을 onClickListener를 통해 직접 예약할 수 없고 RemoteViews의 setOnClickPendingIntent 메소드를 통해 예약할 수 있다. 이때 callback 함수를 setOnClickPendingIntent의 인자로 넘겨주는 것이 아니라 수행하고자 하는 행동을 담은 Intent를 담은 PendingIntent를 인자로 넘겨주어야 한다(PendingIntent를 다른 어플리케이션에게 주면 그 어플리케이션은 자신의 권한으로 PendingIntent를 수행하는 것이 아니라 PendingIntent를 보낸 어플리케이션의 권한으로 intent를 수행한다). 위젯은 AppWidgetProvider 클래스가 브로드캐스트를 수신하여 위젯을 업데이트하는 구조이다. 따라서, 이미지뷰를 클릭했을 때 브로드캐스팅 통해 위젯을 업데이트하여 새로고침하도록 한다. 사용자 정의 액션을 담은 명시적 인텐트를 브로드캐스팅하는 PendingIntent를 만들어서 RemoteViews의 setPendingIntent의 인자로 넘겨주고 AppWidgetProvider의 onReceive 콜백에서 이 액션을 처리하도록 한다.

RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.weather_widget);

// 중략...

// AppWidgetProvider를 상속 받은 WeatherWidget 클래스를 가리키는 인텐트 생성
Intent refreshIntent = new Intent(context, WeatherWidget.class);
// 생성한 인텐트에 사용자 정의 액션을 세팅
refreshIntent.setAction(ACTION_REFRESH_WIDGET);
// 생성한 인텐트를 브로드캐스팅하는 PendingIntent 생성
PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, refreshIntent, 0);
// 이미지뷰 id와 생성한 PendingIntent를 인자로 RemoteViews의 setOnClickPendingIntent 메서드 호출
views.setOnClickPendingIntent(R.id.refresh_widget, pendingIntent);
public class WeatherWidget extends AppWidgetProvider {

    // 중략...
    @Override
    public void onReceive(Context context, Intent intent) {
        // 수신한 인텐트가 새로고침 버튼 클릭을 통해 전달된 인텐트인지 확인
        if (ACTION_REFRESH_WIDGET.equals(intent.getAction())) {

                // 중략...

                // 위젯 업데이트
                updateAppWidget(context, appWidgetManager, widgetId);
        }
        else super.onReceive(context, intent);
    }

    // 중략...
}
mangab-heo commented 2 years ago

MVVM 아키텍처 적용

mangab-heo commented 2 years ago

데이터 바인딩 적용

데이터 바인딩을 사용하면 특정 뷰에 대응되는 바인딩 객체를 만들 수 있다. 이 바인딩 객체는 앱에서 직접 생성하는 것이 아니라 안드로이드 시스템이 생성하며 앱에서는 이 바인딩 객체를 참조하여 사용한다. 바인딩 객체는 뷰 객체를 멤버로 가지고 있어서 데이터 바인딩을 사용하면 특정 뷰를 참조하고자 할 때 뷰 계층을 돌면서 찾지 않아도 되는 장점이 있다. 또 데이터 바인딩을 사용하면 뷰 객체에서 바인딩 객체가 가지고 있는 변수를 참조할 수 있어서 뷰 객체의 속성 값을 일일이 결정해주지 않아도 된다.

데이터 바인딩을 사용하기 위해서는 모듈 수준의 build.gradle 파일을 수정해야 한다.

android {
    // 중략...

    dataBinding {
        enabled = true
    }
}

build.gradle 파일을 수정한 후에는 layout 파일에서 변수를 생성해 사용할 수 있는데 먼저 최상위 태그를 layout 태그로 감싸줘야 한다. 그리고 그 안에 data 태그를 만들어 생성하고자 하는 변수를 정의할 수 있다.

<!-- 최상위 태그를 layout 태그로 바꿈 -->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">
    <!-- data 태그 안에서 변수 선언>
    <data>
        <!-- variable 태그를 통해서 이름이 weatherViewModel이고 type이 WeatherViewModel인 변수 선언 -->
        <variable
            name="weatherViewModel"
            type="com.example.weatherapp.weather.WeatherViewModel" />
    </data>

    <!-- 중략... -->
             <!-- @{}를 통해 variable 태그로 선언한 weatherViewModel변수를 사용할 수 있다. -->
            <TextView
                android:text="@{weatherViewModel.weatherText}"
             />

    <!-- 중략... -->
</layout>

앱에서 바인딩 객체를 사용할 때는 여러 방법이 있지만(참고) 여기서는 DataBindingUtil.setContentView 메소드를 이용하여 바인딩 객체를 참조한다. 만약 데이터 바인딩을 통해 레이아웃 파일의 표현식에서 라이브 데이터를 사용한다면 바인딩 객체의 LifecycleOwner를 지정해줘야 한다. 바인딩 객체가 가지고 있는 변수에 실제 데이터를 할당하여 사용한다.

class MainActivity : RxAppCompatActivity() {
    // 바인딩 객체가 사용할 실제 데이터를 담고 있는 변수
    private val mainViewModel : MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 바인딩 객체는 직접 생성하는 것이 아니라 안드로이드 시스템으로부터 제공받음
        val binding: ActivityMainBinding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        // 바인딩 객체를 통해 뷰가 라이브 데이터를 사용한다면 바인딩 객체에 LifecycleOwner를 지정
        binding.setLifecycleOwner { this.lifecycle }
        // 바인딩 객체의 변수에 실제 데이터 할당
        binding.mainViewModel = mainViewModel

        // 중략...
}
mangab-heo commented 2 years ago

뷰모델 클래스 생성 및 구현

뷰모델 클래스는 뷰를 그리는데 필요한 데이터를 담고 있는 클래스이다. 뷰(액티비티와 프래그먼트 포함)는 뷰모델 클래스를 참조하지만 뷰모델 클래스는 뷰를 참조하지 않도록 한다. 이렇게 함으로써 뷰의 구성이 바뀌어도 뷰모델의 로직을 수정하지 않을 수 있다. 또 안드로이드가 제공하는 ViewModel클래스를 상속받아 뷰모델 클래스를 구현하면 화면 회전과 같은 configuration change에서도 데이터가 소멸되지 않을 수 있다. onSaveInstanceState 콜백이나 데이터베이스를 통해 데이터를 저장하는 것과 ViewModel 클래스를 비교한 표는 다음과 같다. viewmodel

뷰모델 클래스를 사용할 때는 직접 생성하는 것이 아니라 안드로이드 시스템을 통해 생성한 뷰모델을 제공받는다.

class MainActivity : RxAppCompatActivity() {
    // by viewModels()를 통해 뷰모델 객체 참조
    private val mainViewModel : MainViewModel by viewModels()

    // 중략...
}

뷰모델의 데이터가 바뀌었을 때 뷰를 뷰모델에서 직접 업데이트하려면 뷰모델에서 뷰를 참조해야하기 때문에 라이브 데이터를 사용해 뷰모델이 뷰를 참조하지 않으면서 뷰를 업데이트하도록 한다. 라이브 데이터를 사용할 때 외부에서 사용하는 데이터는 LiveData<>로 선언하여 외부에서 라이브 데이터의 값을 변경하지 못하도록 한다. LiveData<>로 선언한 변수는 내부에서 사용하는 MutableLiveData<> 변수를 가리키도록하여 내부에서만 값을 변경할 수 있도록 한다.

class MainViewModel : ViewModel() {
    // 외부에서 사용하는 라이브데이터는 LiveData<> 타입으로 선언
    // LiveData<> 타입의 라이브데이터는 값을 변경할 수 없기 때문에 MutableLiveData<> 변수로 초기화하여 사용
    val weatherData: LiveData<WeatherData> by lazy { _weatherData }
    // 내부에서 사용할 MutableLiveData<>변수 
    private val _weatherData: MutableLiveData<WeatherData> = MutableLiveData()

    // 어떤 라이브데이터를 통해 값이 결정되는 라이브데이터는 Transformations.map을 이용
    val weatherText: LiveData<String> = Transformations.map(_weatherData) {
        if (it.curPty == "없음") it.curSky else it.curPty
    }

    // 중략...
}

RecyclerView는 어댑터를 통해 데이터 변화를 감지하기 때문에 RecyclerView 객체에서 직접 데이터를 참조하지 않는다. 이렇게 뷰모델이 제공하는 라이브데이터를 직접 사용하지 않고 어떤 로직을 거쳐 사용해야 할 때 바인딩 어댑터를 사용할 수 있다. 바인딩 어댑터를 통해 뷰에 커스텀 속성을 부여할 수 있다.

// 사용할 바인딩 어댑터를 정의한 파일(WeatherBindingAdapter.kt)

// app:item이라는 속성 생성
@BindingAdapter("app:item")
// RecyclerView에서 이 속성을 사용하고 속성 값으로 WeatherData 타입을 받음
fun setItems(recyclerView: RecyclerView, item: WeatherData?) {
    // 라이브데이터를 통해 item 값이 바뀌면 아래 로직이 수행된다.
    item?.let {
        // 새로 어댑터를 만들고 바뀐 데이터를 전달한다. 기존에 만들었던 어댑터를 사용하도록 수정할 예정
        val recyclerAdapter = RecyclerAdapter(item.weatherHours)

        recyclerView.adapter = recyclerAdapter

        recyclerAdapter.notifyDataSetChanged()

    }
}

RecyclerAdapter안에서 뷰홀더도 바인딩 객체를 만들어서 사용하는데 이때 바인딩 객체에 대응되는 아이템 뷰는 라이브데이터를 사용하지 않기 때문에 바인딩 객체의 executePendingBindings 메소드를 통해 뷰를 업데이트 시킨다.

<!-- 리싸이클러뷰의 아이템 레이아웃에서 데이터 바인딩 사용 -->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
         <!-- 여기서는 라이브데이터가 아닌 타입의 변수를 사용 -->
        <variable
            name="item"
            type="com.example.weatherapp.data.WeatherData.WeatherHour" />
    </data>

    // 중략...
</layout>  
    class RecyItemViewHolder(var binding: RecyclerItemBinding) : RecyclerView.ViewHolder(binding.root) {

        fun bind(item: WeatherHour) {
            binding.item = item
            // 바뀐 데이터로 뷰를 직접 업데이트 시킴
            binding.executePendingBindings()
        }

        companion object {
            fun from(parent: ViewGroup): RecyItemViewHolder {
                val layoutInflater = LayoutInflater.from(parent.context)
                // 바인딩 객체 생성
                val binding = RecyclerItemBinding.inflate(layoutInflater, parent, false)

                // ViewHolder를 생성할 때 binding 객체를 생성자 매개변수로 받음
                return RecyItemViewHolder(binding)
            }
        }
    }
mangab-heo commented 2 years ago

repository 클래스 생성 및 구현

기존 코드는 뷰모델에서 통신하여 데이터를 얻어오는데 이렇게 할 경우 뷰모델마다 통신하는 코드가 중복으로 존재한다. 만약 데이터를 처리하는 로직이 수정되면 뷰모델에 사용된 코드를 전부 수정해야 하므로 뷰 모델은 repository 클래스를 통해서만 데이터를 전달받거나 저장하도록 한다. 이때도 마찬가지로 뷰모델에서만 repository 클래스를 참조하도록 하여 뷰모델 클래스가 수정되어도 repository 클래스를 수정할 필요가 없도록 한다.

또 repository class는 data source 인터페이스를 통해 데이터를 꺼내오거나 저장하도록 하는데 data source를 구현한 remote data source와 local data source에서 각각의 로직에 맞게 구현한다. 예를 들어, 뷰모델에서 repository가 제공하는 refreshData 메소드를 호출하면 repository는 local data source 클래스의 saveData 메소드를 통해 데이터를 데이터베이스에 저장하고 remote data source 클래스의 getData 메소드를 통해 새로운 데이터를 전달 받을 수 있다. data source로부터 데이터를 전달받을 때 mapper를 통해 data source가 사용하는 데이터를 repository가 사용하는 데이터로 바꾸는 작업을 거친다.

날씨 어플리케이션의 데이터 레이어 구조는 다음과 같다. image

날씨 어플리케이션의 경우 데이터를 매번 새로 불러오기만 하면 되므로 뷰모델에서 사용할 데이터를 제공하는 메소드만 repository 인터페이스에 정의되어 있다.

interface Repository {
    // 미세먼지 데이터를 반환하는 메소드
    fun getObservablePmData(stationName: String): Observable<PmData>
    // 날씨 데이터를 반환하는 메소드
    fun getObservableWeatherData(gridLocation: WeatherGrid.LatXLngY): Observable<WeatherData>
}
class DefaultRepository : Repository {
    // repository class에서 사용할 data source를 직접 생성한다. 추후 라이브러리를 통해 주입될 예정.
    private val remoteDataSource: DataSource = RemoteDataSource()

    // 중략...
}
class MainViewModel : ViewModel() {
    // 뷰모델에서 사용할 repository를 직접 생성한다. 추후 라이브러리를 통해 주입될 예정.
    private val repository: Repository = DefaultRepository()

    // 뷰모델에서 직접 통신하지 않고 데이터를 repository에서 가져오도록 한다.
    fun refreshWeatherData(gridLocation: WeatherGrid.LatXLngY, stationName: String) {
        try {
        // repository에서 데이터를 비동기로 받는다.
            repository.getObservablePmData(stationName)
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe{
                    _pmData.value = it
                }
        // 중략...
        } catch (exception: Exception) {

            // 중략...
        }
    }
}
mangab-heo commented 2 years ago

패키지 구조 정리

repository와 data source가 정의된 data 패키지, 날씨 화면 액티비티와 뷰모델, 바인딩 어댑터 등이 정의된 weather 패키지, 위치 권한 관련, 파일 읽기 관련 함수가 정의된 util 패키지, 위젯 관련 클래스가 정의된 widget 패키지로 분리한다.

image

mangab-heo commented 2 years ago

hilt 라이브러리를 이용한 의존성 주입

hilt 라이브러리는 의존성 주입을 하는데 사용되는 라이브러리이다. 의존성 주입을 하는 라이브러리를 검색해보니 dagger, koin, hilt가 있었는데 안드로이드 공식 문서에서 hilt를 사용하기에 여기서도 hilt를 사용한다. hilt는 dagger 기반 라이브러리라고 한다. 의존성 주입에 관한 설명은 hilt 관련 안드로이드 공식 문서에 잘 나와 있는데 어떤 클래스가 다른 클래스에 의존할 때 이 다른 클래스를 외부에서 생성하여 제공하는 것이 의존성 주입이고 이 의존성을 자동으로 생성하여 제공해주는 라이브러리가 hilt이다. hilt 라이브러리는 android 클래스에 컨테이너를 제공하고 또 수명 주기를 자동으로 관리해주는 장점이 있다.

hilt를 사용하기 위해서는 프로젝트 수준의 그래들 파일과 앱 수준의 그래들 파일을 수정해야 하고 어플리케이션 클래스에 @HiltAndroidApp 어노테이션을 붙여야 한다.

buildscript {

    // 중략...

    dependencies {
        // 중략...
        classpath("com.google.dagger:hilt-android-gradle-plugin:2.38.1")
}
plugins {
    // 중략...
    id 'dagger.hilt.android.plugin'
}

dependencies {
    // 중략...
    implementation "com.google.dagger:hilt-android:2.38.1"
    kapt "com.google.dagger:hilt-android-compiler:2.38.1"
}

kapt {
    correctErrorTypes = true
}
@HiltAndroidApp
class WeatherApplication: Application()

날씨 앱에서는 ViewModel이 Repository에 의존하고 Repository가 RemoteDataSource에 의존한다. 각각의 클래스에서 각각의 종속 항목을 직접 생성하여 사용하고 있었는데 이를 hilt 라이브러리가 주입하도록 한다. 뷰모델 클래스에서 hilt를 사용하려면 @HiltViewModel 어노테이션을 뷰모델 클래스에 붙여야 한다. 이렇게 어떤 안드로이드 클래스에 어노테이션을 붙이면 해당 클래스에 종속된 안드로이드 클래스에도 @ AndroidEntryPoint 주석을 붙여야 한다. 따라서 이 뷰모델을 사용하는 액티비티 클래스에도 @ AndroidEntryPoint를 붙인다. 또 hilt 라이브러리를 통해 의존성을 주입 받을 때는 주입 받을 필드나 생성자에 @ Inject 어노테이션을 붙인다.

// hilt를 사용할 뷰모델에 @HiltViewModel 어노테이션을 붙인다.
@HiltViewModel
// 의존성을 주입받을 생성자에 @Inject 어노테이션을 붙인다.
class WeatherViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    private val repository: Repository
) : ViewModel() {
// hilt를 사용하는 뷰모델을 사용하는 액티비티에도 @AndroidEntryPoint 어노테이션을 붙인다.
@AndroidEntryPoint
class WeatherActivity : RxAppCompatActivity() {
    // 중략...
}

이렇게 @ Inject을 붙이면 @ Inject가 붙여진 그 의존성을 생성하는 방법을 hilt에게 알려줘야하는데 hilt 모듈을 생성하여 그 방법을 알려줄 수 있다. 클래스에 @ InstallIn 어노테이션을 붙여서 hilt 모듈을 정의한다. @ InstallIn 어노테이션에 해당 모듈이 제공하는 종속 항목의 사용 가능 범위를 전달할 수 있다. 예를 들어, @ InstallIn(ActivityComponent::class) 어노테이션을 사용하면 이 모듈의 종속 항목은 앱의 모든 액티비티에서 사용 가능하다.

// @Module 어노테이션을 이용하여 hilt 모듈 정의
@Module
// @Installin 어노테이션에 SingletonComponent::class를 전달하여 앱 전체에 종속 항목을 제공할 수 있도록 한다.
@InstallIn(SingletonComponent::class)
object AppModule {
    // 중략...
}

종속 항목을 제공하는 함수를 hilt 모듈 안에 정의해야 hilt 모듈이 해당 종속 항목을 제공할 수 있다. 종속 항목을 제공하는 함수는 @ Provides와 @ Bind 어노테이션을 붙여서 정의할 수 있는데 @ Provides는 외부 라이브러리 클래스를 제공하거나 빌더 패턴으로 인스턴스를 생성할 때 사용할 수 있고 @ Bind는 인터페이스를 제공해야할 때 사용할 수 있다. 날씨 앱에서는 @ Provides 어노태이션을 사용한다.

@Module
@InstallIn(SingletonComponent::class)
object AppModule {

    // 중략...

    // @Singleton 어노테이션을 사용하면 앱 전체에 걸쳐 한개의 인스턴스만 생성된다.
    @Singleton
    @Provides
    // DataSource 타입의 종속 항목을 생성하는 방법을 정의한 함수
    fun provideRemoteDataSource(): DataSource {
        return RemoteDataSource()
    }
}

이때 만약에 같은 데이터 타입의 종속 항목을 제공하는 방법을 정의한 함수가 여러 개라면 @ Qualifier 어노테이션을 통해 정의한 어노테이션을 통해 구별하도록 한다. 예를 들어, DataSource 인터페이스를 구현한 RemoteDataSource와 LocalDataSource를 hilt 라이브러리가 제공한다면 각각을 제공하는 함수에 @ Qualifier를 통해 정의한 어노테이션을 붙여 구분한다.

@Module
@InstallIn(SingletonComponent::class)
object AppModule {

    // 같은 타입을 제공하는 함수를 구별하기 위한 RemoteDataSource 어노테이션 클래스 생성
    @Qualifier
    @RemoteDataSource
    annotation class RemoteDataSource

    @Singleton
    // RemoteDataSource 어노테이션이 붙여진 종속 항목을 생성하는 방법을 정의한 함수
    @RemoteDataSource
    @Provides
    fun provideRemoteDataSource(): DataSource {
        return com.example.weatherapp.data.source.remote.RemoteDataSource()
    }
}

@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {

    @Singleton
    @Provides
    fun provideRepository(
        // @RemoteDataSource 어노테이션이 붙여진 함수를 이용하여 종속 항목을 생성한다.
        @AppModule.RemoteDataSource remoteDataSource: DataSource
    ): Repository {
        return DefaultRepository(remoteDataSource)
    }
}