2

My goal is to send async accelerometer readings to a server in periodic payloads.

Accelerometer data continues while offline and concurrently during the network requests, so I'll need to handle network failures as well as data that arrives during the duration of each network request.

My inelegant approach is to append each new update to an array:

motionManager.startAccelerometerUpdates(to: .main) { data, error in
    dataArray.append(data)
}

And then periodically send a group of values to the server (network is my wrapper around NWPathMonitor()):

let timer = Timer(fire: Date(), interval: 5, // Every 5 seconds
              repeats: true, block: { timer in
                if network.connected {
                    postAccelerometerData(payload: dataArray) { success
                        if success {
                            dataArray.removeAll()
                        }
                    }
                }
            })


RunLoop.current.add(timer, forMode: RunLoop.Mode.default)

The major issue with this approach is that the elements of the array added between when the network request fires and when it completes would be removed from the array without ever being sent to the server.

I've had some ideas about adding a queue and dequeuing X elements on for each network request (but then do I add them back to the queue if the request fails?).

I can't help but think there is a better way to approach this using Combine to "stream" these accelerometer updates to some sort of data structure to buffer them, and then send those on to a server.

The postAccelerometerData() function just encodes a JSON structure and makes the network request. Nothing particularly special there.

M-P
  • 4,909
  • 3
  • 25
  • 31
  • The question as you posed is too broad and also subjective. What's "an elegant way to use Combine"? What exactly are you having difficulty with? Does it matter that the data is "sensor data"? And what exactly is this data queue that can be periodically sent to the server? My suggestion is to try to minimize the problem to focus on the area of concern, show some code, and if there an *inelegant* way (or downright broken) - show that too. – New Dev May 07 '21 at 12:01
  • @NewDev Thank you for the suggestions. I've narrowed the scope of the question specifically to accelerometer data and provided my _inelegant_ current approach. My intuition says that this design could be better handled by `Combine` since it's "streaming" values from `Accelerometer -> Local Data Buffer of some form -> Server` – M-P May 07 '21 at 14:07

2 Answers2

2

Combine has a way to collect values for a certain amount of time and emit an array. So, you could orchestrate your solution around that approach, by using a PassthroughSubject to send each value, and the .collect operator with byTime strategy to collect the values into an array.

let accelerometerData = PassthroughSubject<CMAccelerometerData, Never>()
motionManager.startAccelerometerUpdates(to: .main) { data, error in
   guard let data = data else { return } // for demo purposes, ignoring errors
   accelerometerData.send(data)
}
// set up a pipeline that periodically sends data to the server
accelerometerData
   .collect(.byTime(DispatchQueue.main, .seconds(5))) // collect for 5 sec
   .sink { dataArray in
       // send to server
       postAccelerometerData(payload: dataArray) { success in
           print("success:", success)
       }
   }
   .store(in: &cancellables) 

The above is a simplified example - it doesn't handle accelerometer errors or network errors - because it seems to be beyond what you're asking in this question. But if you need to handle network errors and, say, retry - then you could wrap postAccelerometerData in a Future and integrate it into the Combine pipeline.

M-P
  • 4,909
  • 3
  • 25
  • 31
New Dev
  • 48,427
  • 12
  • 87
  • 129
  • Thank you. This was helpful. I followed your advice on wrapping `postAccelerometerData` in a `Future` and now have a well-composed `Combine` pipeline to handle the happy path for this process. The ability to retry in the pipeline is also excellent. My next challenge is accumulating this data stream when offline, and then sending it in a network request (or requests) when connectivity returns. – M-P May 07 '21 at 20:24
  • 2
    @mpatzer, sounds like a good follow up new question :) – New Dev May 07 '21 at 21:10
1

Edited

Another option would be to use throttle(for:scheduler:latest:) and a @Published dataArray:

Let's say that postAccelerometerData(payload:) only displays the data and let's consider the following - voluntarily - simple view :

struct ContentView: View {
    @ObservedObject var viewModel = ViewModel()
    var body: some View {
        Text(viewModel.description)
            .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(viewModel: ViewModel())
    }
}

To avoid updating the view too frequently it's better wrap the motion manager in a separate class :

class MotionManagerObserver {
    var motionManager: CMMotionManager
    var cancellables: Set<AnyCancellable> = []
    @Published var data: CMAccelerometerData? = nil
    init() {
        motionManager = CMMotionManager()
        // "start" the accelerometer
        motionManager.startAccelerometerUpdates(to: .main) { data, _  in
            self.data = data
        }
    }
}

now ViewModel only listens to the $dataArray :

class ViewModel: ObservableObject, CustomStringConvertible {
    var cancellables: Set<AnyCancellable> = []
    let observer: MotionManagerObserver
    @Published var description: String
    
    init() {
        description = ""
        observer = MotionManagerObserver()
        // the magic happens here with `throttle(for:scheduler:latest:)`
        // `postAccelerometerData(payload:)` will be called every 5s
        // and the `Text` view will be updated
        observer.$data
            .throttle(for: 5, scheduler: RunLoop.main, latest: true)
            .sink(receiveValue: postAccelerometerData(payload:))
            .store(in: &cancellables)
    }
  
    func postAccelerometerData(payload: CMAccelerometerData?) {
        description = payload?.description ?? "N/A"
    }
}

The main idea here is that by doing:

motionManager.startAccelerometerUpdates(to: .main) { data, _  in
    self.data = data
}

you write into data each time you receive an update so if it's @Published you can simply use it's publisher and throttle to post updates to the server at the interval you choose. And of course if you network layer needs to handle errors and retry it is still possible but it is probably a good idea to keep that a separate matter

AnderCover
  • 2,488
  • 3
  • 23
  • 42
  • 1
    Thank you. This is another great approach and I hadn't previously been exposed to `throttle`. I think my biggest issue will be back pressure of the constant sensor updates while sending them to the network (especially when offline or poor connection). Looking at `throttle(for:scheduler:latest:)` led me to find `buffer(size:prefetch:whenFull:)` which looks promising. – M-P May 10 '21 at 16:35
  • 1
    Combine is full of surprises ! I haven't been exposed to `buffer(size:prefetch:whenFull:)`, yet ;) . So thank you and I'll try to integrate it in my answer if I have the time – AnderCover May 10 '21 at 16:38
  • Reading this answer from @NewDev https://stackoverflow.com/a/63257257/1425697. I'm not really sure how you would want to use buffer here. If you want an array in `postAccelerometerData` you probably want to use collect or simply handle the buffering in your network layer – AnderCover May 10 '21 at 19:40
  • 1
    Likely in the networking layer. I ended up making a new question for the next step in my process: https://stackoverflow.com/questions/67475707/how-to-use-combine-to-buffer-data-locally-while-offline-then-send-to-server-whe – M-P May 10 '21 at 19:56
  • @mpatzer, bear in mind that `throttle` drops additional values that arrive within a window – New Dev May 10 '21 at 21:48