Skip to content

학교를 선택하고 게임화면으로 가서 화면을 터치하며 점수를 올리는 앱

Notifications You must be signed in to change notification settings

hamfan524/TouchSchool

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 

Repository files navigation

🏫 학교 대항전

📖 목차

  1. 소개
  2. 개발환경 및 라이브러리
  3. Tree
  4. 키워드
  5. 타임라인
  6. 실행화면
  7. 트러블슈팅

🌱 소개

학교를 선택하고 게임화면으로 가서 화면을 터치하며 점수를 올리는 앱입니다.

🧑🏻‍💻 팀원

최동호 황성진 김성엽 김혜란 윤준성
Kakao-Talk-Photo-2023-09-19-15-30-11 1f

⚙️ 개발 환경 및 라이브러리

swift xcode Firebase Alamofire GoogleMobileAds The Composable Architecture

🌲 Tree

📦TouchSchool
 ┣ 📂Preview Content
 ┃ ┗ 📂Preview Assets.xcassets
 ┃ ┃ ┗ 📜Contents.json
 ┣ 📂iOS
 ┃ ┣ 📂AD
 ┃ ┃ ┗ 📜InterstitialAdView.swift
 ┃ ┣ 📂Game
 ┃ ┃ ┣ 📜GameFeature.swift
 ┃ ┃ ┣ 📜GameView.swift
 ┃ ┃ ┗ 📜SmokeEffectView.swift
 ┃ ┣ 📂Helpers
 ┃ ┃ ┣ 📂Font
 ┃ ┃ ┃ ┣ 📜Giants-Bold.otf
 ┃ ┃ ┃ ┗ 📜Recipekorea.ttf
 ┃ ┃ ┣ 📂Sound
 ┃ ┃ ┃ ┣ 📜buttomBGM.mp3
 ┃ ┃ ┃ ┣ 📜buttonBGM.mp3
 ┃ ┃ ┃ ┣ 📜errorBGM.mp3
 ┃ ┃ ┃ ┗ 📜mainBGM.mp3
 ┃ ┃ ┣ 📜.DS_Store
 ┃ ┃ ┣ 📜ActiveAlert.swift
 ┃ ┃ ┣ 📜Audio.swift
 ┃ ┃ ┣ 📜Colors.swift
 ┃ ┃ ┣ 📜Enums +.swift
 ┃ ┃ ┣ 📜Helpers.swift
 ┃ ┃ ┣ 📜MultitouchRepresentable.swift
 ┃ ┃ ┗ 📜MultitouchView.swift
 ┃ ┣ 📂Info
 ┃ ┃ ┣ 📜GridCell.swift
 ┃ ┃ ┣ 📜InfoView.swift
 ┃ ┃ ┗ 📜IntroGridView.swift
 ┃ ┣ 📂Main
 ┃ ┃ ┣ 📜MainFeature.swift
 ┃ ┃ ┗ 📜MainView.swift
 ┃ ┣ 📂Model
 ┃ ┃ ┣ 📜Person.swift
 ┃ ┃ ┣ 📜School.swift
 ┃ ┃ ┗ 📜Smoke.swift
 ┃ ┣ 📂Rank
 ┃ ┃ ┣ 📜RankFeature.swift
 ┃ ┃ ┗ 📜RankView.swift
 ┃ ┣ 📂Search
 ┃ ┃ ┣ 📜SearchBar.swift
 ┃ ┃ ┣ 📜SearchFeature.swift
 ┃ ┃ ┣ 📜SearchGuide.swift
 ┃ ┃ ┗ 📜SearchView.swift
 ┃ ┣ 📂Service
 ┃ ┃ ┣ 📜FirestoreAPI.swift
 ┃ ┃ ┗ 📜SearchResult.swift
 ┃ ┗ 📜.DS_Store
 ┣ 📜.DS_Store
 ┣ 📜ContentView.swift
 ┣ 📜Info.plist
 ┗ 📜TouchSchoolApp.swift

🔑 키워드

  • The Composable Architecture
  • URLSession
  • Alamofire
  • Firebase

⏰ 타임라인

Step 1 타임라인
  • 23.10.11 ~ 23.10.17
    • 프로젝트 시작
    • 학교검색화면 구현
    • 메인화면 구현
  • 23.10.19 ~ 23.10.26
    • 초,중,고등학교 데이터 가져와서 저장
    • URLSession -> Alamofire 라이브러리 적용
    • 학교정보 검색 시 필터링 기능 구현
Step 2 타임라인
  • 23.11.02 ~ 23.11.03
    • Firebase와 데이터 주고 받는 함수들 구현
    • 학교 선택 시 Firebase에 추가 및 데이터 연결
    • 배경화면 수정
    • 깃 컨벤션 템플릿 추가
  • 23.11.06 ~ 23.11.15
    • 랭킹 화면 추가
    • 게임 기능 구현 완료
    • 앱 실행 시 메인화면이 먼저나오도록 로직 수정
  • 23.11.16
    • 앱 종료 후 들어왔을 때 데이터 남게 수정
    • 터치시 이벤트 추가
Step 3 타임라인
  • 23.11.17
    • 비정상적인 값 검출 및 초기화 기능 구현
  • 23.11.19 ~ 23.11.21
    • UI 수정 및 sound데이터 추가
    • 게임 화면 터치 애니메이션 추가
  • 23.11.22
    • 메인BGM, 터치BGM, 오류BGM 추가
    • 게임화면 멀티터치 기능 구현
    • 앱 아이콘 생성
  • 23.11.23
    • 커스텀 폰트 적용
    • Sound 인스턴스 생성 후 재사용 로직으로 변경
    • 오디오 재생 백그라운드 스레드에서 처리
Step 4 타임라인
  • 24.04.03
    • TCA 라이브러리 설치
    • 학교 공공데이터 API 받아오는 로직 뷰모델에서 리듀서에서 받아오고 관리하도록 로직 수정
  • 24.04.04
    • 변수의 True/False로 화면을 전환하던 방식에서 Navigation을 사용하도록 수정
    • SearchView MVVM 패턴에서 TCA 패턴으로 변경
  • 24.04.06
    • RankView, GameView TCA 패턴 적용
    • 이전에 사용하던 ViewModel들 삭제
  • 24.04.08
    • 파이어베이스 리스너 리듀서에서 처리하도록 적용
    • Main리듀서에서 Rank리듀서와 GameReducer에 데이터 전달하도록 구현

📱 실행 화면

앱 실행 학교선택
게임화면 랭킹화면

❓ 트러블 슈팅

Step1

GameView 멀티 터치가 안되던 이슈
  • GameView에서 화면을 터치할 때 여러 손가락으로 화면을 터치하면 먹히는 현상이 있었습니다.

  • SwiftUI는 직접적인 멀티터치 처리를 위한 API를 제공하지 않기에 기본적인 onTapGesture 대신에 더 낮은 수준의 이벤트 처리를 사용했습니다.

  • 멀티터치 기능을 활성화하기 위해 UIViewRepresentable프로토콜을 준수하는 MultitouchRepresentableUIView의 하위 클래스인 MultitouchView를 추가했습니다.

  • makeUIView(context:) 이 메소드는 MultitouchView 생성을 담당합니다. MultitouchViewtouchBegan 클로저를 설정합니다. 이 클로저는 MultitouchView에서 터치가 시작될 때마다 호출됩니다.

struct MultitouchRepresentable: UIViewRepresentable {
    var touchBegan: ((CGPoint) -> Void)

    func makeUIView(context: Context) -> MultitouchView {
        let view = MultitouchView()
        view.touchBegan = touchBegan
        return view
    }

    func updateUIView(_ uiView: MultitouchView, context: Context) {
    }
}
  • isMultipleTouchEnabled = true 이 코드를 통해 뷰가 여러 개의 동시 터치 이벤트를 감지할 수 있었습니다.
  • touchesBegan(_:with:) 이는 뷰에서 새로운 터치가 감지될 때마다 호출되는 UIView의 메서드를 재정의합니다. 이 메서드는 각 터치를 처리하고 터치 위치와 함께 touchBegan클로저를 호출합니다.
import UIKit

class MultitouchView: UIView {
    var touchBegan: ((CGPoint) -> Void)?

    override init(frame: CGRect) {
        super.init(frame: frame)
        isMultipleTouchEnabled = true 
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        touches.forEach { touch in
            let location = touch.location(in: self)
            touchBegan?(location)
        }
    }
}
  • 사용자가 화면을 터치할 때마다 MultitouchViewtouchesBegan(_:with:)가 호출되고, 이는 차례로 touchBegan클로저를 호출하게 됩니다.
  • 이 코드를 통해 여러 터치 이벤트를 동시에 감지하고 응답할 수 있게 해결했습니다.

Step 2

터치 효과음 관련 에러
  • SoundSetting 클래스에서 playSound메서드로 버튼을 클릭하면 효과음이 나오는 효과를 주려고했습니다.
  • playSound 메서드에서는 매번 새로운 `AVAudioPlayer 인스턴스를 생성하고 있었고, 이는 비효율적이며 성능 저하를 일으키고 있었습니다.
  • 또, playSound 메서드가 메인 스레드에서 오디오를 재생하여 화면이 뚝뚝 끊기는 문제가 있었습니다.
//변경 전
class SoundSetting: ObservableObject {
    static let instance = SoundSetting()
    
    var player: AVAudioPlayer?
    
    enum SoundOption: String {
        case mainBGM = "mainBGM"
        case buttonBGM = "buttonBGM"
        case errorBGM = "errorBGM"
    }
    
    func playSound(sound: SoundOption) {
        
        guard let url = Bundle.main.url(forResource: sound.rawValue, withExtension: ".mp3") else { return }
        
        do {
            player = try AVAudioPlayer(contentsOf: url)
            player?.play()
            player?.volume = 1
        } catch {
            print("재생하는데 오류가 발생했습니다. \(error.localizedDescription)")
        }
    }
  • 각 사운드별로 AVAudioPlayer 인스턴스를 사전에 생성하고 저장하는 방식으로 SoundSetting 클래스를 수정했습니다.
  • 또한, 오디오 재생을 백그라운드 스레드에서 수행하도록 변경했습니다.
// 수정 후
class SoundSetting: ObservableObject {
    static let instance = SoundSetting()
    private var players: [SoundOption: AVAudioPlayer] = [:]

    enum SoundOption: String, CaseIterable {
        case mainBGM = "mainBGM"
        case buttonBGM = "buttomBGM"
        case errorBGM = "errorBGM"
    }

    init() {
        for sound in SoundOption.allCases {
            if let url = Bundle.main.url(forResource: sound.rawValue, withExtension: "mp3") {
                do {
                    let player = try AVAudioPlayer(contentsOf: url)
                    player.prepareToPlay()
                    players[sound] = player
                } catch {
                    print("오디오 플레이어 초기화 실패: \(error)")
                }
            } else {
                print("사운드 파일 로드 실패: \(sound.rawValue).mp3")
            }
        }
    }

    func playSound(sound: SoundOption) {
        DispatchQueue.global().async {
            if let player = self.players[sound], !player.isPlaying {
                player.play()
                player.volume = 0.1
            }
        }
    }
}

Step 3

배포 후 이용자 후기로 알게된 오류
  • GameView에서 화면을 아주 많이 터치하다보면 어느순간부터 화면이 버벅이고 멈추는 현상이 있었습니다.
  • 팀원분들과 제작하면서 테스트를 할 때는 기능만 동작하는것만 확인되면 뒤로 돌아가 다른 기능 테스트를 진행하였기에 몰랐었던 오류였습니다.
  • 초기 상태: smokes 배열에 화면 탭 이벤트마다 새로운 Smoke 객체가 추가되어 사용자가 화면을 많이 탭할수록 배열의 크기가 계속 증가하는 상태였습니다.
  • 배열의 크기가 커질수록, 각 탭 이벤트에 대해 더 많은 SmokeEffectView 인스턴스를 렌더링해야 했고, 이로 인해 UI가 버벅이는 성능 문제가 발생했습니다.
// 수정 전

  ForEach(smokes.indices, id: \.self) { index in
                let smoke = smokes[index]
                if smoke.showEffect {
                    SmokeEffectView()
                        .rotationEffect(.degrees(smoke.angle))
                        .opacity(smoke.opacity)
                        .offset(x: smoke.location.x - UIScreen.main.bounds.width / 2,
                                y: smoke.location.y - UIScreen.main.bounds.height / 2)
                        .onAppear {
                            withAnimation(.linear(duration: 1)) {
                                smokes[index].opacity = 0
                                smokes[index].angle += 30
                            }
                        }
                }
            }

private func handleTap(location: CGPoint) {
        let angle = Double.random(in: -30...30)
        // Smoke 객체를 계속하여 추가
        smokes.append(Smoke(location: location,
                            showEffect: true,
                            angle: angle,
                            opacity: 1))
        myTouchCount += 1
        soundSetting.playSound(sound: .buttonBGM)
        vm.newAdd()
        
        withAnimation {
            self.animationAmount += 360
        }

해결방법

  • 어떻게 해결해야할지 계속 생각하다가 FPS 게임에서 벽에 총을 계속하여 쏘다보면 총자국이 처음 쐈던거부터 사라지는게 생각이 났습니다.
  • 배열 크기 제한: 먼저 smokes 배열의 크기를 30으로 제한하였습니다.
  • 코드 변경: handleTap 함수에서 새로운 Smoke 객체를 배열에 추가하기 전에 배열의 크기가 이미 30이면, 가장 오래된 요소(0번 인덱스)를 제거합니다. 그런 다음 새로운 요소를 배열에 추가합니다.
  • 결과: 이 방식은 smokes 배열의 크기를 일정하게 유지하여 각 탭 이벤트에 대해 일정한 수의 SmokeEffectView 인스턴스만 렌더링하도록 보장하였고, 화면이 버벅이는 문제를 해결할 수 있었습니다.
// 수정 후

ForEach(smokes) { smoke in
                if smoke.showEffect {
                    SmokeEffectView(smoke: smoke)
                        .rotationEffect(.degrees(smoke.angle))
                        .opacity(smoke.opacity)
                        .offset(x: smoke.location.x - UIScreen.main.bounds.width / 2,
                                y: smoke.location.y - UIScreen.main.bounds.height / 2)
                        .onAppear {
                            withAnimation(.linear(duration: 1)) {
                                smokes[smokes.firstIndex(where: { $0.id == smoke.id })!].opacity = 0
                                smokes[smokes.firstIndex(where: { $0.id == smoke.id })!].angle += 30
                            }
                        }
                }
            }

 private func handleTap(location: CGPoint) {
        let angle = Double.random(in: -30...30)
        let newSmoke = Smoke(location: location,
                             showEffect: true,
                             angle: angle,
                             opacity: 1)
        //  배열의 크기가 이미 30이면, 가장 오래된 요소(0번 인덱스)를 제거
        if smokes.count >= 30 {
            smokes.removeFirst()
        }
        smokes.append(newSmoke)
        myTouchCount += 1
        soundSetting.playSound(sound: .buttonBGM)
        vm.newAdd()
        
        withAnimation {
            self.animationAmount += 360
        }
        
    }

Step 4

TCA아키텍처 적용하며 생겼던 문제들

1. 문제 정의

  • 자식 리듀서와 공유하고 있는 부모 리듀서의 state값이 변해도 자식리듀서에서 값이 업데이트 되지 않는 오류

2. 사실 수집

  • MainFeature에서 스냅샷 리스너로 바라보고 있는 랭킹 데이터가 업데이트 되어도 RankFeature에서 공유받고 있는 랭킹 데이터는 업데이트 되지 않음

3. 원인 추론

  • 일반적으로 부모 리듀서가 자식 리듀서에게 값을 전달해주는 방식으로는, 부모 리듀서에서 state값의 상태 변화를 자식 리듀서는 알 수 없음

4. 해결방법

  • SharedState 사용법을 학습하지 못해, 다른 방식으로 해결
  • 메인 path에서 추가되는 리듀서가 최대 1개씩 추가되는 경우밖에 없어, 직접 찾아서 업데이트 해주는 방식을 사용
  • 부모리듀서에서 state의 값이 변할 때, Path에서 키값 ID를 찾아서 해당 자식리듀서를 업데이트 해줌
  • 현재 PointFree 강의를 구매해 SharedState 사용방법을 학습 중

해결 코드

// 전역으로 pathId 선언
var pathId: String = ""

// 부모 리듀서
@Reducer
struct MainFeature {
    @ObservableState
    struct State: Equatable {
        var mySchool: SchoolInfo = .init(name: "", adres: "", seq: "", count: 0)
        var mySchoolRank: Int = 0
        var schools: IdentifiedArrayOf<School> = []
        var schoolInfo: IdentifiedArrayOf<SchoolInfo> = []
        var path = StackState<Path.State>()
    }
    
    enum Action {
        // 나머지 액션들
        
        case openRankView
        case path(StackAction<Path.State, Path.Action>)
        case rankDataResponse([SchoolInfo])
    }
    
    @Dependency(\.firestoreAPI) var firestoreAPI

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {

            // 나머지 로직
                
            // 처음 랭킹뷰 열 때 스택에 추가
            case .openRankView:
                pathId = "rank"
                state.path.append(.rankScene(RankFeature.State(
                    mySchool: state.mySchool,
                    mySchoolRank: state.mySchoolRank,
                    schoolInfo: state.schoolInfo
                )))
                return .none
                
            case let .rankDataResponse(schoolInfo):
                
                if !state.path.isEmpty {
                    let key = state.path.ids.first!
                    
                    // state값이 변하면, pathId에 따라 id값을 찾아서 해당 리듀서를 업데이트
                    switch pathId {
                    case "game":
                        state.path[id: key] = .gameScene(GameFeature.State(
                            mySchool: state.mySchool,
                            mySchoolRank: state.mySchoolRank,
                            schoolInfo: state.schoolInfo
                        ))
                        
                    case "rank":
                        state.path[id: key] = .rankScene(RankFeature.State(
                            mySchool: state.mySchool,
                            mySchoolRank: state.mySchoolRank,
                            schoolInfo: state.schoolInfo,
                            openAdView: false
                        ))
                        
                    default:
                        break
                    }
                }
                
                return .run { send in
                    try await send(.dataResponse(self.searchResult.fetch([eSchoolUrl, mSchoolUrl, hSchoolUrl])))
                }
                
            }
        }
        .forEach(\.path, action: \.path) {
            Path()
        }
    }
}



// 자식 리듀서
@Reducer
struct RankFeature {
    @ObservableState
    struct State: Equatable {
        var mySchool: SchoolInfo = .init(name: "", adres: "", seq: "", count: 0)
        var mySchoolRank: Int = 0
        var schoolInfo: IdentifiedArrayOf<SchoolInfo> = []
    }
    
    enum Action {
        case tabBackButton
    }
    
    @Dependency(\.dismiss) var dismiss

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .tabBackButton:
                return .run { _ in
                    await self.dismiss()
                }
            }
        }
    }
}

About

학교를 선택하고 게임화면으로 가서 화면을 터치하며 점수를 올리는 앱

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages