• 썸네일

    [GNU BOB] 3. JetPack glance를 이용해서 위젯 구현하기

    https://github.com/ikhyeons/gnuBobWidget


    그누밥 프로젝트에서 위젯의 프론트를 담당할 부분을 제작한다.

    안드로이드 위젯을 제작하기 위하여 AndroidStudio를 사용하여 제작하였으며, AndroidStudio에서 기본적으로 제공하는 new Project의 Empty Activity에서 시작해서 구현하였다.


    1. JetPack glance 개발 초기 세팅 하기

    Empty Activity에서 새 프로젝트를 생성하면, 아래 사진과 같은 형식의 폴더 구조를 가지게 된다.

    폴더 구조 사진

    위젯을 제작하는 데에 있어서 아래 파일을 집중적으로 다룬다.

    • AndroidManifest.xml - 위젯의 진입점 또는 위젯이 모바일기기에서 동작할 때의 허가 같은 것을 조절할 수 있다.

    • ui.theme 패키지 - 해당 프로젝트에서 사용하는 디자인 CSS같은 것들을 미리 지정해 놓을 수 있다.

    • MainActivity.kt - 앱을 실행시켰을 때의 진입점으로 사용되며, AndroidManifest.xml에서 activity의 android:name으로 진입점을 수정할 수 있다.

    • res - 프로그램에서 사용될 리소스들을 모아놓는다. 상수 또는 프로그램에서 수정할 일이 크게 없는 문자열과 데이터와 같은 리소스들이 들어간다.

    • Gradle Scripts - npm의 pakage.js처럼 dependancy들을 관리할 수 있다. 해당 목록에 디펜던시를 추가하고 세이브하면 android studio에서 자동으로 다운하여 적용한다.


    먼저. gladle에 Glance를 추가하여 위젯을 개발할 수 있도록 디펜더시를 추가하였다.

    또한 http통신으로 ajax데이터를 내가만든 node서버에서 받아와야 하기 때문에 okhttp라는 라이브러리 또한 설치하였다.

    implementation ("androidx.glance:glance-appwidget:1.0.0")

    implementation ("androidx.glance:glance-material:1.0.0")

    implementation ("androidx.glance:glance-material3:1.0.0")

    implementation("com.squareup.okhttp3:okhttp:4.12.0")


    2. 구현하기

    나는 최종 목적이 위젯이기 때문에 위젯을 어떻게 만드는지에 대하여 검색하였다. 그러던 중에 JetPack Compose라는 라이브러리를 찾았고, 해당 라이브러리에서 제공하는 Glance를 이용하여 위젯을 구현하였다.

    Glance로 위젯을 제작하기 위해서 GlanceAppWidgetReceiver를 진입점으로 설정하고 실제 위젯을 등록해야 한다.

    //소스 파일에 glance 리시버 생성.
    package com.example.basicscodelab.glance
    import androidx.glance.appwidget.GlanceAppWidget
    import androidx.glance.appwidget.GlanceAppWidgetReceiver
    import com.example.basicscodelab.MyAppWidget
    
    class MyAppWidgetReceiver : GlanceAppWidgetReceiver() {
        // Let MyAppWidgetReceiver know which GlanceAppWidget to use
        override val glanceAppWidget: GlanceAppWidget = MyAppWidget()
    }

    glance의 리시버를 생성하고


    AndroidManifest.xml에 위젯의 진입점을 receiver에 등록한다.

    <receiver android:name=".MyAppWidgetReceiver"android:exported="true">
        <intent-filter>
            <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
        </intent-filter>
        <meta-data android:name="android.appwidget.provider"android:resource="@xml/new_app_widget_info" />
    </receiver>

    이러면 위젯을 생성하였을 때 내가 glance로 생성한 위젯이 생성된다.

    이후에 MyAppWidget을 수정하여 위젯을 구현할 수 있다.

    찾아본 kotlin android 강의에서는 xml을 이용하여 버튼, 박스들을 생성하고 해당 엘리먼트를 타겟으로 css를 지정하는 방식으로 구현을 하고 있었다. 그러다 검색을 계속 해 보니 이를 jetPack을 이용해서 구현하는 것을 발견하여 이 방식을 적용하기로 하였다. 이 방식은 마치 React의 컴포넌트를 조립하는 것과 매우 흡사하였기 때문에 간단하게 적용할 수 있었다.


    2.1 컴포넌트 생성.

    class MyAppWidget : GlanceAppWidget() {
        override suspend fun provideGlance(context: Context, id: GlanceId) {
            provideContent {
                // create your AppWidget here
                MyContent()
            }
        }
    }
    @Composableprivate fun MyContent() {}


    위의 @Composable 애노테이션을 이용하여 리액트에서의 컴포넌트와 같은 개념을 생성할 수 있다. 이후 해당 컴포넌트의 body에서 라이브러리에 내장된 엘리멘트들을 가져와 렌더링 한다. 아래 예는 row형의 엘리멘트를 렌더링 하는 것.


    class MyAppWidget : GlanceAppWidget() {
        override suspend fun provideGlance(context: Context, id: GlanceId) {
            provideContent {
                // create your AppWidget here
                MyContent()
            }
        }
    }
    
    @Composableprivate fun MyContent() {
        Row() {
            //row의 body안에 다른 element를 또 위치하여 복잡한 구성을 구현할 수 있다.
        }
    }


    2.2 css 수정

    이후 modifier를 이용하여 해당 엘리먼트의 스타일을 수정할 수 있으며, 몇몇 스타일들은 해당 엘리멘트에 내장된 인수를 받아서 스타일한다.

    @Composableprivate fun MyContent() {
        Row(
            modifier = GlanceModifier
            .clickable({cOrG.value=i})
            .background(Color(204, 153, 255)) // 여기에 원하는 배경색 설정
            .padding(8.dp)
            .cornerRadius(16.dp)
        ) {
            Text(
                text = "$i",
                style = TextStyle(textAlign = TextAlign.Center)
            )
        }
    }

    2.3 상태관리

    상태에 따른 데이터를 바꾸고 싶을 땐 remember를 이용하여 변동되는 상태를 선언하고,

    이를 통하여 선언된 상태는 변경 될 때 마다 react처럼 자동으로 화면이 갱신된다.

    //선언은 이렇게
    var cOrG = remember {mutableStateOf("가좌캠")}


    3. 배포

    android studio의 build → Generate Signed Bundle or APK를 이용하여 배포판의 apk파일을 생성할 수 있다. 이렇게 만든 gnu_bob 프로젝트를 우리학교 학생들이 사용할 수 있도록 에브리타임에 업로드하였다.


    완성 화면 사진

    완성 화면 사진

    2024. 2. 18 26 0

  • 썸네일

    [GNU BOB] 2. 서버 및 크롤러 구현

    https://github.com/ikhyeons/gnuBob


    그누밥 위젯에서 사용할 학식 데이터를 얻기 위해서 경상국립대 학식 홈페이지의 데이터를 가져오면 좋겠다고 생각하였다. 이때 Selenium이라는 크롤링 라이브러리를 이용하여 구현하였다.

    selenium


    패키지 설치

    서버로 사용할 Express, selenium을 설치한다.

    npm install express

    npm install selenium-webdriver

    1. Selenium

    셀레니움은 웹 테스트를 수행할 수 있도록 해 주는 프레임워크이다. 사람이 직접 웹에 접속해서 해야 하는 동작을 자동화 할 수 있다.


    builder의 build메서드를 이용하여 드라이버를 생성하고

    해당 드라이버에 url을 입력해서 초기에 진입할 페이지를 지정한다.

    let driver = await new Builder().forBrowser(Browser.CHROME).build();
    
    const url =
        "https://www.gnu.ac.kr/main/ad/fm/foodmenu/selectFoodMenuView.do?mi=1341";
    await driver.get(url);
    
    // 이렇게 웹페이지를 띄우면 find element로 찾고자 하는 html Element를 지정할 수 있다.
    const chilamBtn = await driver.findElement(By.xpath(xpath));
    
    //찾은 html Element에 클릭 이벤트를 작동시키고 싶으면
    await chilamBtn.click();
    
    //이벤트 동작 이후 페이지 로딩을 기다려야 한다.
    await driver.manage().setTimeouts({ implicit: 500 });

    내가 접속해야 하는 페이지는 위 rul로 들어갔을 때, 칠암캠퍼스 버튼 → 학생식당 버튼을 눌러서 얻을 수 있는데,

    버튼의 동작을 실행시키기 위해서는 해당 findElement로 받은 요소에서 click이벤트를 사용하여 구현할 수 있다.

    2. 데이터의 제공 및 크롤러 구현

    서버에서 제공할 내용은 2가지 이다.

    크롤링된 데이터를 전달하고, app을 배포하기 위한 다운로드 링크를 제공한다.


    다운로드 링크는 파일의 경로를 받아서 제공한다. 

    app.get("/download", async (req: Request, res: Response) => {  
        const filepath = `${__dirname}/releaseFile/GNU_BOB_v1.3.5.zip`;  
        const filename = "GNU_BOB_v1.3.5.zip";
        console.log("다운했습니다."); 
        if (fs.existsSync(filepath)) {
        res.download(filepath, filename);
      } else {
        res.status(403).send("해당 파일이 존재하지 않음");
      }
    });

     

    크롤링한 데이터를 제공하는 것은 처음에는 해당 크롤링 로직을 그대로 api에 작성하여 배포하였으나, 몇 가지 문제가 발생했다.

    위젯에서 데이터가 필요할 때마다 계속 요청이 날아오게 될 텐데, 이렇게 되면 서버에서는 계속 driver를 생성하고.. 버튼을 누르고.. 이런 과정을 모두 거치고 난 뒤 json을 제공하게 된다.

    그러면 라우터가 응답하는 시간이 매우 오래 걸리고, 서버에 가해지는 부담이 커질 것이다.

    그래서 크롤링은 하루에 한 번만 진행하고, 크롤링된 json데이터를 db에 저장한 뒤 요청이 들어왔을 경우에 db의 데이터를 조회하여 제공하게끔 구현하였다.

    //크롤링한 데이터를 db에 저장하기!
    import { FieldPacket, RowDataPacket } from "mysql2";
    import getConnection from "./connection";
    import getBobFunc from "./getBobFunc";
    import schedule from "node-schedule";
    
    interface DayInBob extends RowDataPacket {  day: string;}
    type DataSet<T> = [T[], FieldPacket[]];
    
    async function getBob() {
      const conn = await getConnection();
     //확인로직 
      let today = new Date();
      let year1 = today.getFullYear(); // 년도
      let month1 = today.getMonth() + 1; // 월
      let date1 = today.getDate(); // 날짜
      console.log("오늘날짜 : ", year1, month1, date1);
    
      const getQuery = "SELECT day FROM bob ORDER BY day DESC LIMIT 1";
      const [thatDayData]: DataSet<DayInBob> = await conn.query(getQuery);
      let thatDay = new Date(thatDayData[0].day);
      let year2 = thatDay.getFullYear(); // 년도
      let month2 = thatDay.getMonth() + 1; // 월
      let date2 = thatDay.getDate(); // 날짜
      if (year1 == year2 && month1 == month2 && date1 == date2) {
        console.log("이미 이번 주 밥을 저장했습니다.");
        conn.release();
      } else {
        //입력 로직
        const bobData = await getBobFunc();
        const bobString = JSON.stringify(bobData);
        const query = "INSERT INTO bob values(DEFAULT, DEFAULT, ?)";
        await conn.query(query, [bobString]);
        conn.release();
      }
      conn.release();
    }
    
    getBob();
    
    // 매일 홈페이지에 밥이 갱신되는 시간에 자동으로 크롤링을 진행
    const job = schedule.scheduleJob("0 30 1 * * *", async () => {
      getBob();
    });


    // 저장된 json데이터를 내려주기!
    app.get("/", async (req: Request, res: Response) => {
      const conn = await getConnection();
      try {
        const getQuery = "SELECT menu, day FROM bob ORDER BY day DESC LIMIT 1";
        const [thatDayData]: DataSet<BobInfo> = await conn.query(getQuery);
        conn.release();
        const returnData = JSON.parse(thatDayData[0].menu);
        return res.json(returnData);
      } catch (e) {
        console.log(e);
        conn.release();
      }
    });


    서버에서 할 수 있는 일은 얼추 완성했다.

    이제 위젯을 생성하고 위젯에서 데이터만 받아오면 된다.

    2024. 2. 14 29 0

  • 썸네일

    [GNU BOB] 1. 프로젝트 구상


    학교 졸업까지 2주정도 남았는데 고등학교 졸업학고 같이 간 친구들 중에

    다른 일들을 한다고 한학기나, 한 학년을 늦게 하게된 친구들이 있다.

    많이들 모여서 밥먹고 놀 때 마다, 내일 학식이나 그날 저녁밥이 뭔지 많이들 물어봤었는데

    그 때 마다 폰에 웹 브라우저를 실행시키고 우리학교 홈페이지를 검색하여 학식 메뉴를 보는것이

    너무 불편했다. 여태 웹 프론트엔드 개발을 위주로 공부하였는데, 결국 웹이면 브라우저를 실행시켜야

    할 것 같아서 위젯이 아니면 의미가 없다고 생각해서 java, kotlin을 학습하고 위젯으로 개발하기로 하였다.


    스터디 진행 2. 4 ~ 2. 8

    학습에 사용한 강의는 다음과 같다.

    1. 인프런 김영한의 자바 입문 - 코드로 시작하는 자바 첫걸음

    2. 인프런 김영한의 실전 자바 - 기본편

    3. 인프런 코틀린 3강으로 끝내기 feat. 안드로이드 개발

    4. Android Developers Jetpack Compose 튜토리얼


    위젯 디자인

    image.png

    버튼을 두어서 어느 캠퍼스인지, 어느 시간대인지를 선택하고, 해당 선택에 변경사항이 생기면

    바로 아래 표에 해당 데이터가 생기도록 만들예정.

    2024. 2. 9 33 0